diff --git a/app/Console/Commands/AiEditsPruneCommand.php b/app/Console/Commands/AiEditsPruneCommand.php new file mode 100644 index 00000000..19341165 --- /dev/null +++ b/app/Console/Commands/AiEditsPruneCommand.php @@ -0,0 +1,69 @@ +option('request-days') ?: config('ai-editing.retention.request_days', 90))); + $ledgerRetentionDays = max(1, (int) ($this->option('ledger-days') ?: config('ai-editing.retention.usage_ledger_days', 365))); + $pretend = (bool) $this->option('pretend'); + + $requestCutoff = now()->subDays($requestRetentionDays); + $ledgerCutoff = now()->subDays($ledgerRetentionDays); + + $requestQuery = AiEditRequest::query() + ->where(function (Builder $query) use ($requestCutoff): void { + $query->where(function (Builder $completedQuery) use ($requestCutoff): void { + $completedQuery->whereNotNull('completed_at') + ->where('completed_at', '<=', $requestCutoff); + })->orWhere(function (Builder $expiredQuery): void { + $expiredQuery->whereNotNull('expires_at') + ->where('expires_at', '<=', now()); + }); + }); + $ledgerQuery = AiUsageLedger::query() + ->where('recorded_at', '<=', $ledgerCutoff); + + $requestCount = (clone $requestQuery)->count(); + $ledgerCount = (clone $ledgerQuery)->count(); + + $this->line(sprintf( + 'AI prune candidates -> requests: %d (<= %s), ledgers: %d (<= %s)', + $requestCount, + $requestCutoff->toDateString(), + $ledgerCount, + $ledgerCutoff->toDateString() + )); + + if ($pretend) { + $this->info('Pretend mode enabled. No records were deleted.'); + + return self::SUCCESS; + } + + $deletedRequests = $requestQuery->delete(); + $deletedLedgers = $ledgerQuery->delete(); + + $this->info(sprintf( + 'Pruned AI data -> requests: %d, ledgers: %d.', + $deletedRequests, + $deletedLedgers + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/AiEditsRecoverStuckCommand.php b/app/Console/Commands/AiEditsRecoverStuckCommand.php new file mode 100644 index 00000000..c80f0bde --- /dev/null +++ b/app/Console/Commands/AiEditsRecoverStuckCommand.php @@ -0,0 +1,179 @@ +option('minutes')); + $shouldRequeue = (bool) $this->option('requeue'); + $shouldFail = (bool) $this->option('fail'); + + if ($shouldRequeue && $shouldFail) { + $this->error('Use either --requeue or --fail, not both.'); + + return self::FAILURE; + } + + $cutoff = now()->subMinutes($minutes); + $requests = AiEditRequest::query() + ->with([ + 'event:id,slug,name', + 'providerRuns' => function (HasMany $query): void { + $query->select(['id', 'request_id', 'provider_task_id', 'attempt']) + ->orderByDesc('attempt'); + }, + ]) + ->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING]) + ->where(function (Builder $query) use ($cutoff): void { + $query + ->where(function (Builder $queuedQuery) use ($cutoff): void { + $queuedQuery->whereNull('started_at') + ->whereNotNull('queued_at') + ->where('queued_at', '<=', $cutoff); + }) + ->orWhere(function (Builder $processingQuery) use ($cutoff): void { + $processingQuery->whereNotNull('started_at') + ->where('started_at', '<=', $cutoff); + }) + ->orWhere(function (Builder $fallbackQuery) use ($cutoff): void { + $fallbackQuery->whereNull('queued_at') + ->whereNull('started_at') + ->where('updated_at', '<=', $cutoff); + }); + }) + ->orderBy('updated_at') + ->get(); + + if ($requests->isEmpty()) { + $this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes)); + + return self::SUCCESS; + } + + $this->table( + ['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'], + $requests->map(function (AiEditRequest $request): array { + $latestTaskId = $this->latestProviderTaskId($request) ?? '-'; + $eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id); + $ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at; + + return [ + (string) $request->id, + $eventLabel, + $request->status, + $ageSource?->toIso8601String() ?? '-', + $latestTaskId, + ]; + })->all() + ); + + if (! $shouldRequeue && ! $shouldFail) { + $this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.'); + + return self::SUCCESS; + } + + if ($shouldFail) { + $count = $this->markAsFailed($requests); + $this->info(sprintf('Marked %d AI edit request(s) as failed.', $count)); + + return self::SUCCESS; + } + + [$processDispatches, $pollDispatches] = $this->requeueRequests($requests); + $this->info(sprintf( + 'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).', + $processDispatches + $pollDispatches, + $processDispatches, + $pollDispatches + )); + + return self::SUCCESS; + } + + /** + * @return array{0:int,1:int} + */ + private function requeueRequests(Collection $requests): array + { + $queueName = $this->runtimeConfig->queueName(); + $processDispatches = 0; + $pollDispatches = 0; + + foreach ($requests as $request) { + if ($request->status === AiEditRequest::STATUS_QUEUED) { + ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName); + $processDispatches++; + + continue; + } + + $providerTaskId = $this->latestProviderTaskId($request); + if ($providerTaskId !== null) { + PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName); + $pollDispatches++; + + continue; + } + + ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName); + $processDispatches++; + } + + return [$processDispatches, $pollDispatches]; + } + + private function markAsFailed(Collection $requests): int + { + $updated = 0; + $now = now(); + + foreach ($requests as $request) { + $request->forceFill([ + 'status' => AiEditRequest::STATUS_FAILED, + 'failure_code' => 'operator_recovery_marked_failed', + 'failure_message' => 'Marked as failed by ai-edits:recover-stuck.', + 'completed_at' => $now, + ])->save(); + + $updated++; + } + + return $updated; + } + + private function latestProviderTaskId(AiEditRequest $request): ?string + { + foreach ($request->providerRuns as $run) { + $taskId = trim((string) ($run->provider_task_id ?? '')); + if ($taskId !== '') { + return $taskId; + } + } + + return null; + } +} diff --git a/app/Filament/Resources/AiStyles/AiStyleResource.php b/app/Filament/Resources/AiStyles/AiStyleResource.php index b7cd7d30..9d49f048 100644 --- a/app/Filament/Resources/AiStyles/AiStyleResource.php +++ b/app/Filament/Resources/AiStyles/AiStyleResource.php @@ -54,6 +54,12 @@ class AiStyleResource extends Resource TextInput::make('name') ->required() ->maxLength(120), + TextInput::make('version') + ->numeric() + ->default(1) + ->disabled() + ->dehydrated(false) + ->helperText('Auto-increments when core style configuration changes.'), TextInput::make('category') ->maxLength(50), TextInput::make('sort') @@ -107,6 +113,9 @@ class AiStyleResource extends Resource ->copyable(), Tables\Columns\TextColumn::make('name') ->searchable(), + Tables\Columns\TextColumn::make('version') + ->sortable() + ->toggleable(), Tables\Columns\TextColumn::make('provider') ->badge(), Tables\Columns\TextColumn::make('provider_model') diff --git a/app/Http/Controllers/Api/EventPublicAiEditController.php b/app/Http/Controllers/Api/EventPublicAiEditController.php index d292ede0..f50cc97d 100644 --- a/app/Http/Controllers/Api/EventPublicAiEditController.php +++ b/app/Http/Controllers/Api/EventPublicAiEditController.php @@ -8,10 +8,12 @@ use App\Models\AiEditRequest; use App\Models\AiStyle; use App\Models\Event; use App\Models\Photo; +use App\Services\AiEditing\AiBudgetGuardService; use App\Services\AiEditing\AiEditingRuntimeConfig; use App\Services\AiEditing\AiStyleAccessService; use App\Services\AiEditing\AiStylingEntitlementService; use App\Services\AiEditing\EventAiEditingPolicyService; +use App\Services\AiEditing\Safety\AiAbuseEscalationService; use App\Services\AiEditing\Safety\AiSafetyPolicyService; use App\Services\EventJoinTokenService; use App\Support\ApiError; @@ -27,9 +29,11 @@ class EventPublicAiEditController extends BaseController private readonly EventJoinTokenService $joinTokenService, private readonly AiSafetyPolicyService $safetyPolicy, private readonly AiEditingRuntimeConfig $runtimeConfig, + private readonly AiBudgetGuardService $budgetGuard, private readonly AiStylingEntitlementService $entitlements, private readonly EventAiEditingPolicyService $eventPolicy, private readonly AiStyleAccessService $styleAccess, + private readonly AiAbuseEscalationService $abuseEscalation, ) {} public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse @@ -95,6 +99,19 @@ class EventPublicAiEditController extends BaseController ); } + $budgetDecision = $this->budgetGuard->evaluateForEvent($event); + if (! $budgetDecision['allowed']) { + return ApiError::response( + $budgetDecision['reason_code'] ?? 'budget_hard_cap_reached', + 'Budget limit reached', + $budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.', + Response::HTTP_FORBIDDEN, + [ + 'budget' => $budgetDecision['budget'], + ] + ); + } + $style = $this->resolveStyleByKey($request->input('style_key')); if ($request->filled('style_key') && ! $style) { return ApiError::response( @@ -126,6 +143,25 @@ class EventPublicAiEditController extends BaseController $safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt); $deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', '')); $sessionId = $this->normalizeOptionalString((string) $request->input('session_id', '')); + $scopeKey = $this->normalizeOptionalString($deviceId ?: $sessionId) ?: 'guest'; + $abuseSignal = null; + $safetyReasons = $safetyDecision->reasonCodes; + if ($safetyDecision->blocked) { + $abuseSignal = $this->abuseEscalation->recordPromptBlock( + (int) $event->tenant_id, + (int) $event->id, + $scopeKey + ); + if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) { + $safetyReasons[] = AiAbuseEscalationService::REASON_CODE; + } + } + + $metadata = (array) $request->input('metadata', []); + if (is_array($abuseSignal)) { + $metadata['abuse'] = $abuseSignal; + } + $metadata['budget'] = $budgetDecision['budget']; $idempotencyKey = $this->resolveIdempotencyKey( $request->input('idempotency_key'), @@ -150,12 +186,12 @@ class EventPublicAiEditController extends BaseController 'input_image_path' => $photoModel->file_path, 'requested_by_device_id' => $deviceId, 'requested_by_session_id' => $sessionId, - 'safety_reasons' => $safetyDecision->reasonCodes, + 'safety_reasons' => $safetyReasons, 'failure_code' => $safetyDecision->failureCode, 'failure_message' => $safetyDecision->failureMessage, 'queued_at' => now(), 'completed_at' => $safetyDecision->blocked ? now() : null, - 'metadata' => $request->input('metadata', []), + 'metadata' => $metadata, ]; $editRequest = AiEditRequest::query()->firstOrCreate( @@ -419,6 +455,7 @@ class EventPublicAiEditController extends BaseController 'id' => $style->id, 'key' => $style->key, 'name' => $style->name, + 'version' => $style->version, 'category' => $style->category, 'description' => $style->description, 'provider' => $style->provider, diff --git a/app/Http/Controllers/Api/Tenant/AiEditController.php b/app/Http/Controllers/Api/Tenant/AiEditController.php index 7451d32c..ac79aba5 100644 --- a/app/Http/Controllers/Api/Tenant/AiEditController.php +++ b/app/Http/Controllers/Api/Tenant/AiEditController.php @@ -7,13 +7,17 @@ use App\Http\Requests\Tenant\AiEditIndexRequest; use App\Http\Requests\Tenant\AiEditStoreRequest; use App\Jobs\ProcessAiEditRequest; use App\Models\AiEditRequest; +use App\Models\AiProviderRun; use App\Models\AiStyle; +use App\Models\AiUsageLedger; use App\Models\Event; use App\Models\Photo; +use App\Services\AiEditing\AiBudgetGuardService; use App\Services\AiEditing\AiEditingRuntimeConfig; use App\Services\AiEditing\AiStyleAccessService; use App\Services\AiEditing\AiStylingEntitlementService; use App\Services\AiEditing\EventAiEditingPolicyService; +use App\Services\AiEditing\Safety\AiAbuseEscalationService; use App\Services\AiEditing\Safety\AiSafetyPolicyService; use App\Support\ApiError; use App\Support\TenantMemberPermissions; @@ -28,9 +32,11 @@ class AiEditController extends Controller public function __construct( private readonly AiSafetyPolicyService $safetyPolicy, private readonly AiEditingRuntimeConfig $runtimeConfig, + private readonly AiBudgetGuardService $budgetGuard, private readonly AiStylingEntitlementService $entitlements, private readonly EventAiEditingPolicyService $eventPolicy, private readonly AiStyleAccessService $styleAccess, + private readonly AiAbuseEscalationService $abuseEscalation, ) {} public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse @@ -123,6 +129,8 @@ class AiEditController extends Controller $event = $this->resolveTenantEventOrFail($request, $eventSlug); TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload'); + $periodStart = now()->startOfMonth(); + $periodEnd = now()->endOfMonth(); $baseQuery = AiEditRequest::query()->where('event_id', $event->id); $statusCounts = (clone $baseQuery) ->select('status', DB::raw('count(*) as aggregate')) @@ -139,6 +147,32 @@ class AiEditController extends Controller $lastRequestedAt = (clone $baseQuery)->max('created_at'); $total = array_sum($statusCounts); + $failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0)); + $moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0); + + $usageQuery = AiUsageLedger::query() + ->where('tenant_id', $event->tenant_id) + ->where('event_id', $event->id) + ->where('recorded_at', '>=', $periodStart) + ->where('recorded_at', '<=', $periodEnd); + $spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0); + $debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count()); + + $providerRunQuery = AiProviderRun::query() + ->whereHas('request', fn ($query) => $query->where('event_id', $event->id)) + ->where('created_at', '>=', $periodStart) + ->where('created_at', '<=', $periodEnd); + $providerRunTotal = (int) (clone $providerRunQuery)->count(); + $providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count(); + $averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0)); + + $failureRate = $total > 0 ? ($failedTotal / $total) : 0.0; + $moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0; + $providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0; + + $failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35); + $latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000)); + $budgetDecision = $this->budgetGuard->evaluateForEvent($event); return response()->json([ 'data' => [ @@ -146,8 +180,27 @@ class AiEditController extends Controller 'total' => $total, 'status_counts' => $statusCounts, 'safety_counts' => $safetyCounts, - 'failed_total' => (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0)), + 'failed_total' => $failedTotal, 'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null, + 'usage' => [ + 'period_start' => $periodStart->toDateString(), + 'period_end' => $periodEnd->toDateString(), + 'debit_count' => $debitCount, + 'spend_usd' => round($spendUsd, 5), + ], + 'observability' => [ + 'failure_rate' => round($failureRate, 5), + 'moderation_hit_rate' => round($moderationHitRate, 5), + 'provider_runs_total' => $providerRunTotal, + 'provider_runs_failed' => $providerRunFailed, + 'provider_failure_rate' => round($providerFailureRate, 5), + 'avg_provider_latency_ms' => $averageProviderLatencyMs, + 'alerts' => [ + 'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)), + 'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs, + ], + ], + 'budget' => $budgetDecision['budget'], ], ]); } @@ -214,6 +267,19 @@ class AiEditController extends Controller ); } + $budgetDecision = $this->budgetGuard->evaluateForEvent($event); + if (! $budgetDecision['allowed']) { + return ApiError::response( + $budgetDecision['reason_code'] ?? 'budget_hard_cap_reached', + 'Budget limit reached', + $budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.', + Response::HTTP_FORBIDDEN, + [ + 'budget' => $budgetDecision['budget'], + ] + ); + } + if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) { return ApiError::response( 'style_not_allowed', @@ -231,6 +297,25 @@ class AiEditController extends Controller $providerModel = $request->input('provider_model') ?: $style->provider_model; $safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt); $requestedByUserId = $request->user()?->id; + $scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user'; + $abuseSignal = null; + $safetyReasons = $safetyDecision->reasonCodes; + if ($safetyDecision->blocked) { + $abuseSignal = $this->abuseEscalation->recordPromptBlock( + (int) $event->tenant_id, + (int) $event->id, + $scopeKey + ); + if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) { + $safetyReasons[] = AiAbuseEscalationService::REASON_CODE; + } + } + + $metadata = (array) $request->input('metadata', []); + if (is_array($abuseSignal)) { + $metadata['abuse'] = $abuseSignal; + } + $metadata['budget'] = $budgetDecision['budget']; $idempotencyKey = $this->resolveIdempotencyKey( $request->input('idempotency_key'), @@ -257,12 +342,12 @@ class AiEditController extends Controller 'negative_prompt' => $negativePrompt, 'input_image_path' => $photo->file_path, 'idempotency_key' => $idempotencyKey, - 'safety_reasons' => $safetyDecision->reasonCodes, + 'safety_reasons' => $safetyReasons, 'failure_code' => $safetyDecision->failureCode, 'failure_message' => $safetyDecision->failureMessage, 'queued_at' => now(), 'completed_at' => $safetyDecision->blocked ? now() : null, - 'metadata' => $request->input('metadata', []), + 'metadata' => $metadata, ] ); @@ -439,6 +524,7 @@ class AiEditController extends Controller 'id' => $style->id, 'key' => $style->key, 'name' => $style->name, + 'version' => $style->version, 'category' => $style->category, 'description' => $style->description, 'provider' => $style->provider, diff --git a/app/Jobs/PollAiEditRequest.php b/app/Jobs/PollAiEditRequest.php index 6e946a36..baa1f0d6 100644 --- a/app/Jobs/PollAiEditRequest.php +++ b/app/Jobs/PollAiEditRequest.php @@ -7,7 +7,10 @@ use App\Models\AiEditRequest; use App\Models\AiProviderRun; use App\Services\AiEditing\AiEditingRuntimeConfig; use App\Services\AiEditing\AiImageProviderManager; +use App\Services\AiEditing\AiObservabilityService; +use App\Services\AiEditing\AiStatusNotificationService; use App\Services\AiEditing\AiUsageLedgerService; +use App\Services\AiEditing\Safety\AiAbuseEscalationService; use App\Services\AiEditing\Safety\AiSafetyPolicyService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -15,6 +18,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; +use Illuminate\Support\Str; +use Throwable; class PollAiEditRequest implements ShouldQueue { @@ -43,6 +48,9 @@ class PollAiEditRequest implements ShouldQueue public function handle( AiImageProviderManager $providers, AiSafetyPolicyService $safetyPolicy, + AiAbuseEscalationService $abuseEscalation, + AiObservabilityService $observability, + AiStatusNotificationService $statusNotifications, AiEditingRuntimeConfig $runtimeConfig, AiUsageLedgerService $usageLedger ): void { @@ -76,15 +84,37 @@ class PollAiEditRequest implements ShouldQueue if ($result->status === 'succeeded') { $outputDecision = $safetyPolicy->evaluateProviderOutput($result); if ($outputDecision->blocked) { + $abuseSignal = $abuseEscalation->recordOutputBlock( + (int) $request->tenant_id, + (int) $request->event_id, + 'provider:'.$request->provider + ); + $safetyReasons = $outputDecision->reasonCodes; + if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) { + $safetyReasons[] = AiAbuseEscalationService::REASON_CODE; + } + $metadata = (array) ($request->metadata ?? []); + $metadata['abuse'] = $abuseSignal; + $request->forceFill([ 'status' => AiEditRequest::STATUS_BLOCKED, 'safety_state' => $outputDecision->state, - 'safety_reasons' => $outputDecision->reasonCodes, + 'safety_reasons' => $safetyReasons, 'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked', 'failure_message' => $outputDecision->failureMessage, + 'metadata' => $metadata, 'completed_at' => now(), ])->save(); + $observability->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_BLOCKED, + $run->duration_ms, + true, + 'poll' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); + return; } @@ -122,6 +152,15 @@ class PollAiEditRequest implements ShouldQueue 'poll_attempt' => $this->pollAttempt, ]); + $observability->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_SUCCEEDED, + $run->duration_ms, + false, + 'poll' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); + return; } @@ -131,8 +170,33 @@ class PollAiEditRequest implements ShouldQueue self::dispatch($request->id, $this->providerTaskId, $this->pollAttempt + 1) ->delay(now()->addSeconds(20)) ->onQueue($runtimeConfig->queueName()); + + return; } + $run->forceFill([ + 'status' => AiProviderRun::STATUS_FAILED, + 'finished_at' => now(), + 'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null, + 'error_message' => sprintf('Polling exhausted after %d attempt(s).', $maxPolls), + ])->save(); + + $request->forceFill([ + 'status' => AiEditRequest::STATUS_FAILED, + 'failure_code' => 'provider_poll_timeout', + 'failure_message' => sprintf('Polling timed out after %d attempt(s).', $maxPolls), + 'completed_at' => now(), + ])->save(); + + $observability->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_FAILED, + $run->duration_ms, + false, + 'poll' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); + return; } @@ -144,5 +208,45 @@ class PollAiEditRequest implements ShouldQueue 'failure_message' => $result->failureMessage, 'completed_at' => now(), ])->save(); + + $observability->recordTerminalOutcome( + $request, + $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED, + $run->duration_ms, + $result->status === 'blocked', + 'poll' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); + } + + public function failed(Throwable $exception): void + { + $request = AiEditRequest::query()->find($this->requestId); + if (! $request) { + return; + } + + if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) { + return; + } + + $message = trim($exception->getMessage()); + $request->forceFill([ + 'status' => AiEditRequest::STATUS_FAILED, + 'failure_code' => 'queue_job_failed', + 'failure_message' => $message !== '' + ? Str::limit($message, 500, '') + : 'AI edit polling failed in queue.', + 'completed_at' => now(), + ])->save(); + + app(AiObservabilityService::class)->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_FAILED, + null, + false, + 'poll_failed_hook' + ); + app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh()); } } diff --git a/app/Jobs/ProcessAiEditRequest.php b/app/Jobs/ProcessAiEditRequest.php index 4e8ac17b..a52ed586 100644 --- a/app/Jobs/ProcessAiEditRequest.php +++ b/app/Jobs/ProcessAiEditRequest.php @@ -7,8 +7,11 @@ use App\Models\AiEditRequest; use App\Models\AiProviderRun; use App\Services\AiEditing\AiEditingRuntimeConfig; use App\Services\AiEditing\AiImageProviderManager; +use App\Services\AiEditing\AiObservabilityService; use App\Services\AiEditing\AiProviderResult; +use App\Services\AiEditing\AiStatusNotificationService; use App\Services\AiEditing\AiUsageLedgerService; +use App\Services\AiEditing\Safety\AiAbuseEscalationService; use App\Services\AiEditing\Safety\AiSafetyPolicyService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -17,6 +20,8 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; +use Throwable; class ProcessAiEditRequest implements ShouldQueue { @@ -43,6 +48,9 @@ class ProcessAiEditRequest implements ShouldQueue public function handle( AiImageProviderManager $providers, AiSafetyPolicyService $safetyPolicy, + AiAbuseEscalationService $abuseEscalation, + AiObservabilityService $observability, + AiStatusNotificationService $statusNotifications, AiEditingRuntimeConfig $runtimeConfig, AiUsageLedgerService $usageLedger ): void { @@ -74,43 +82,121 @@ class ProcessAiEditRequest implements ShouldQueue $result = $providers->forProvider($request->provider)->submit($request); $this->finalizeProviderRun($providerRun, $result); - $this->applyProviderResult($request->fresh(['outputs']), $result, $safetyPolicy, $runtimeConfig, $usageLedger); + $this->applyProviderResult( + $request->fresh(['outputs']), + $providerRun, + $result, + $safetyPolicy, + $abuseEscalation, + $observability, + $statusNotifications, + $runtimeConfig, + $usageLedger + ); + } + + public function failed(Throwable $exception): void + { + $request = AiEditRequest::query()->find($this->requestId); + if (! $request) { + return; + } + + if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) { + return; + } + + $message = trim($exception->getMessage()); + $request->forceFill([ + 'status' => AiEditRequest::STATUS_FAILED, + 'failure_code' => 'queue_job_failed', + 'failure_message' => $message !== '' + ? Str::limit($message, 500, '') + : 'AI edit processing failed in queue.', + 'completed_at' => now(), + ])->save(); + + app(AiObservabilityService::class)->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_FAILED, + null, + false, + 'process_failed_hook' + ); + app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh()); } private function finalizeProviderRun(AiProviderRun $run, AiProviderResult $result): void { + $missingTaskId = $result->status === 'processing' + && (! is_string($result->providerTaskId) || trim($result->providerTaskId) === ''); + + $status = $missingTaskId + ? AiProviderRun::STATUS_FAILED + : ($result->status === 'succeeded' + ? AiProviderRun::STATUS_SUCCEEDED + : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED)); + $run->forceFill([ 'provider_task_id' => $result->providerTaskId, - 'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED), + 'status' => $status, 'http_status' => $result->httpStatus, - 'finished_at' => $result->status === 'processing' ? null : now(), + 'finished_at' => $status === AiProviderRun::STATUS_RUNNING ? null : now(), 'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null, 'cost_usd' => $result->costUsd, 'request_payload' => $result->requestPayload, 'response_payload' => $result->responsePayload, - 'error_message' => $result->failureMessage, + 'error_message' => $missingTaskId + ? 'Provider returned processing state without task identifier.' + : $result->failureMessage, ])->save(); } private function applyProviderResult( AiEditRequest $request, + AiProviderRun $providerRun, AiProviderResult $result, AiSafetyPolicyService $safetyPolicy, + AiAbuseEscalationService $abuseEscalation, + AiObservabilityService $observability, + AiStatusNotificationService $statusNotifications, AiEditingRuntimeConfig $runtimeConfig, AiUsageLedgerService $usageLedger ): void { if ($result->status === 'succeeded') { $outputDecision = $safetyPolicy->evaluateProviderOutput($result); if ($outputDecision->blocked) { + $abuseSignal = $abuseEscalation->recordOutputBlock( + (int) $request->tenant_id, + (int) $request->event_id, + 'provider:'.$request->provider + ); + $safetyReasons = $outputDecision->reasonCodes; + if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) { + $safetyReasons[] = AiAbuseEscalationService::REASON_CODE; + } + $metadata = (array) ($request->metadata ?? []); + $metadata['abuse'] = $abuseSignal; + $request->forceFill([ 'status' => AiEditRequest::STATUS_BLOCKED, 'safety_state' => $outputDecision->state, - 'safety_reasons' => $outputDecision->reasonCodes, + 'safety_reasons' => $safetyReasons, 'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked', 'failure_message' => $outputDecision->failureMessage, + 'metadata' => $metadata, 'completed_at' => now(), ])->save(); + $observability->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_BLOCKED, + $providerRun->duration_ms, + true, + 'process' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); + return; } @@ -149,21 +235,49 @@ class ProcessAiEditRequest implements ShouldQueue 'source' => 'process_job', ]); + $observability->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_SUCCEEDED, + $providerRun->duration_ms, + false, + 'process' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); + return; } if ($result->status === 'processing') { + $providerTaskId = trim((string) ($result->providerTaskId ?? '')); + if ($providerTaskId === '') { + $request->forceFill([ + 'status' => AiEditRequest::STATUS_FAILED, + 'failure_code' => 'provider_task_id_missing', + 'failure_message' => 'Provider returned processing state without a task identifier.', + 'completed_at' => now(), + ])->save(); + + $observability->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_FAILED, + $providerRun->duration_ms, + false, + 'process' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); + + return; + } + $request->forceFill([ 'status' => AiEditRequest::STATUS_PROCESSING, 'failure_code' => null, 'failure_message' => null, ])->save(); - if ($result->providerTaskId !== null && $result->providerTaskId !== '') { - PollAiEditRequest::dispatch($request->id, $result->providerTaskId, 1) - ->delay(now()->addSeconds(20)) - ->onQueue($runtimeConfig->queueName()); - } + PollAiEditRequest::dispatch($request->id, $providerTaskId, 1) + ->delay(now()->addSeconds(20)) + ->onQueue($runtimeConfig->queueName()); return; } @@ -176,5 +290,14 @@ class ProcessAiEditRequest implements ShouldQueue 'failure_message' => $result->failureMessage, 'completed_at' => now(), ])->save(); + + $observability->recordTerminalOutcome( + $request, + $result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED, + $providerRun->duration_ms, + $result->status === 'blocked', + 'process' + ); + $statusNotifications->notifyTerminalOutcome($request->fresh()); } } diff --git a/app/Models/AiStyle.php b/app/Models/AiStyle.php index f19dedad..51035f7e 100644 --- a/app/Models/AiStyle.php +++ b/app/Models/AiStyle.php @@ -10,9 +10,37 @@ class AiStyle extends Model { use HasFactory; + protected static function booted(): void + { + static::creating(function (self $style): void { + if ((int) ($style->version ?? 0) < 1) { + $style->version = 1; + } + }); + + static::updating(function (self $style): void { + $versionedFields = [ + 'prompt_template', + 'negative_prompt_template', + 'provider', + 'provider_model', + 'metadata', + 'is_premium', + 'requires_source_image', + ]; + + if ($style->isDirty($versionedFields)) { + $current = max(1, (int) ($style->getOriginal('version') ?? 1)); + $requested = max(1, (int) ($style->version ?? 0)); + $style->version = max($requested, $current + 1); + } + }); + } + protected $fillable = [ 'key', 'name', + 'version', 'category', 'description', 'prompt_template', @@ -32,6 +60,7 @@ class AiStyle extends Model 'requires_source_image' => 'boolean', 'is_premium' => 'boolean', 'is_active' => 'boolean', + 'version' => 'integer', 'sort' => 'integer', 'metadata' => 'array', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0a26195f..4dd9161e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -176,10 +176,12 @@ class AppServiceProvider extends ServiceProvider $deviceId = trim((string) $request->header('X-Device-Id', '')); $scope = $deviceId !== '' ? 'device:'.$deviceId : 'ip:'.($request->ip() ?? 'unknown'); $key = 'ai-edit-guest-submit:'.$token.':'.$scope; + $eventKey = 'ai-edit-guest-submit:event:'.$token; return [ Limit::perMinute(max(1, (int) config('ai-editing.abuse.guest_submit_per_minute', 8)))->by($key), Limit::perHour(max(1, (int) config('ai-editing.abuse.guest_submit_per_hour', 40)))->by($key), + Limit::perMinute(max(1, (int) config('ai-editing.abuse.guest_submit_per_event_per_minute', 40)))->by($eventKey), ]; }); @@ -195,11 +197,14 @@ class AppServiceProvider extends ServiceProvider RateLimiter::for('ai-edit-tenant-submit', function (Request $request) { $tenantId = (string) ($request->attributes->get('tenant_id') ?? 'tenant'); $userId = (string) ($request->user()?->id ?? 'guest'); + $eventSlug = (string) ($request->route('eventSlug') ?? 'event'); $key = 'ai-edit-tenant-submit:'.$tenantId.':'.$userId; + $eventKey = 'ai-edit-tenant-submit:event:'.$tenantId.':'.$eventSlug; return [ Limit::perMinute(max(1, (int) config('ai-editing.abuse.tenant_submit_per_minute', 30)))->by($key), Limit::perHour(max(1, (int) config('ai-editing.abuse.tenant_submit_per_hour', 240)))->by($key), + Limit::perMinute(max(1, (int) config('ai-editing.abuse.tenant_submit_per_event_per_minute', 120)))->by($eventKey), ]; }); diff --git a/app/Services/AiEditing/AiBudgetGuardService.php b/app/Services/AiEditing/AiBudgetGuardService.php new file mode 100644 index 00000000..73ec5ed2 --- /dev/null +++ b/app/Services/AiEditing/AiBudgetGuardService.php @@ -0,0 +1,221 @@ +copy()->startOfMonth(); + $periodEnd = $now->copy()->endOfMonth(); + + $tenantSettings = (array) ($event->tenant?->settings ?? []); + $softCap = $this->resolveCap( + Arr::get($tenantSettings, 'ai_editing.budget.soft_cap_usd'), + config('ai-editing.billing.budget.soft_cap_usd') + ); + $hardCap = $this->resolveCap( + Arr::get($tenantSettings, 'ai_editing.budget.hard_cap_usd'), + config('ai-editing.billing.budget.hard_cap_usd') + ); + + if ($softCap !== null && $hardCap !== null && $softCap > $hardCap) { + [$softCap, $hardCap] = [$hardCap, $softCap]; + } + + $spendUsd = (float) (AiUsageLedger::query() + ->where('tenant_id', $event->tenant_id) + ->where('recorded_at', '>=', $periodStart) + ->where('recorded_at', '<=', $periodEnd) + ->where('entry_type', AiUsageLedger::TYPE_DEBIT) + ->sum('amount_usd') ?: 0.0); + + $softReached = $softCap !== null && $spendUsd >= $softCap; + $hardReached = $hardCap !== null && $spendUsd >= $hardCap; + + $hardStopEnabled = (bool) config('ai-editing.billing.budget.hard_stop_enabled', true); + $overrideUntil = Arr::get($tenantSettings, 'ai_editing.budget.override_until'); + $overrideActive = $this->isOverrideActive($overrideUntil, $now); + + if ($softReached) { + $this->emitBudgetAlert($event, 'soft_cap_reached', [ + 'spend_usd' => $spendUsd, + 'soft_cap_usd' => $softCap, + 'hard_cap_usd' => $hardCap, + ]); + } + + if ($hardReached) { + $this->emitBudgetAlert($event, 'hard_cap_reached', [ + 'spend_usd' => $spendUsd, + 'soft_cap_usd' => $softCap, + 'hard_cap_usd' => $hardCap, + 'hard_stop_enabled' => $hardStopEnabled, + 'override_active' => $overrideActive, + ]); + } + + $allowed = ! ($hardReached && $hardStopEnabled && ! $overrideActive); + + return [ + 'allowed' => $allowed, + 'reason_code' => $allowed ? null : 'budget_hard_cap_reached', + 'message' => $allowed ? null : 'The AI editing budget for this billing period has been exhausted.', + 'budget' => [ + 'period_start' => $periodStart->toDateString(), + 'period_end' => $periodEnd->toDateString(), + 'current_spend_usd' => round($spendUsd, 5), + 'soft_cap_usd' => $softCap, + 'hard_cap_usd' => $hardCap, + 'soft_reached' => $softReached, + 'hard_reached' => $hardReached, + 'hard_stop_enabled' => $hardStopEnabled, + 'override_active' => $overrideActive, + ], + ]; + } + + private function resolveCap(mixed $tenantValue, mixed $defaultValue): ?float + { + if (is_numeric($tenantValue)) { + $resolved = (float) $tenantValue; + + return $resolved >= 0 ? $resolved : null; + } + + if (is_numeric($defaultValue)) { + $resolved = (float) $defaultValue; + + return $resolved >= 0 ? $resolved : null; + } + + return null; + } + + private function isOverrideActive(mixed $overrideUntil, Carbon $now): bool + { + if (! is_string($overrideUntil) || trim($overrideUntil) === '') { + return false; + } + + try { + $deadline = Carbon::parse($overrideUntil); + } catch (\Throwable) { + return false; + } + + return $deadline->isFuture(); + } + + /** + * @param array $context + */ + private function emitBudgetAlert(Event $event, string $type, array $context): void + { + $cooldownMinutes = max(1, (int) config('ai-editing.billing.budget.alert_cooldown_minutes', 30)); + $cacheKey = sprintf('ai-editing:budget-alert:%s:tenant:%d:event:%d', $type, $event->tenant_id, $event->id); + + if (! Cache::add($cacheKey, 1, now()->addMinutes($cooldownMinutes))) { + return; + } + + Log::warning('AI budget threshold reached', array_merge($context, [ + 'type' => $type, + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + ])); + + $this->notifyTenant($event, $type, $context); + } + + /** + * @param array $context + */ + private function notifyTenant(Event $event, string $type, array $context): void + { + /** @var Tenant|null $tenant */ + $tenant = $event->tenant()->with('user')->first(); + if (! $tenant) { + return; + } + + $notificationType = $type === 'hard_cap_reached' + ? 'ai_budget_hard_cap' + : 'ai_budget_soft_cap'; + + $log = app(TenantNotificationLogger::class)->log($tenant, [ + 'type' => $notificationType, + 'channel' => 'system', + 'status' => 'sent', + 'sent_at' => now(), + 'context' => [ + 'scope' => 'ai', + 'threshold' => $type, + 'event_id' => (int) $event->id, + 'event_slug' => (string) $event->slug, + 'event_name' => $this->resolveEventName($event->name), + 'spend_usd' => round((float) ($context['spend_usd'] ?? 0), 5), + 'soft_cap_usd' => is_numeric($context['soft_cap_usd'] ?? null) ? (float) $context['soft_cap_usd'] : null, + 'hard_cap_usd' => is_numeric($context['hard_cap_usd'] ?? null) ? (float) $context['hard_cap_usd'] : null, + 'hard_stop_enabled' => (bool) ($context['hard_stop_enabled'] ?? config('ai-editing.billing.budget.hard_stop_enabled', true)), + 'override_active' => (bool) ($context['override_active'] ?? false), + ], + ]); + + $userId = (int) ($tenant->user_id ?? 0); + if ($userId > 0) { + TenantNotificationReceipt::query()->create([ + 'tenant_id' => (int) $tenant->id, + 'notification_log_id' => (int) $log->id, + 'user_id' => $userId, + 'status' => 'delivered', + ]); + } + } + + private function resolveEventName(mixed $name): ?string + { + if (is_string($name) && trim($name) !== '') { + return trim($name); + } + + if (is_array($name)) { + foreach ($name as $candidate) { + if (is_string($candidate) && trim($candidate) !== '') { + return trim($candidate); + } + } + } + + return null; + } +} diff --git a/app/Services/AiEditing/AiObservabilityService.php b/app/Services/AiEditing/AiObservabilityService.php new file mode 100644 index 00000000..2a86ccad --- /dev/null +++ b/app/Services/AiEditing/AiObservabilityService.php @@ -0,0 +1,90 @@ +format('YmdH'); + $prefix = sprintf('ai-editing:obs:tenant:%d:event:%d:hour:%s', $request->tenant_id, $request->event_id, $bucket); + + Cache::add($prefix.':total', 0, now()->addHours(8)); + $total = (int) Cache::increment($prefix.':total'); + + if ($status === AiEditRequest::STATUS_SUCCEEDED) { + Cache::add($prefix.':succeeded', 0, now()->addHours(8)); + Cache::increment($prefix.':succeeded'); + } elseif ($status === AiEditRequest::STATUS_BLOCKED) { + Cache::add($prefix.':blocked', 0, now()->addHours(8)); + Cache::increment($prefix.':blocked'); + } elseif ($status === AiEditRequest::STATUS_FAILED) { + Cache::add($prefix.':failed', 0, now()->addHours(8)); + Cache::increment($prefix.':failed'); + } + + if ($moderationBlocked) { + Cache::add($prefix.':moderation_blocked', 0, now()->addHours(8)); + Cache::increment($prefix.':moderation_blocked'); + } + + if (is_int($durationMs) && $durationMs > 0) { + Cache::add($prefix.':duration_total_ms', 0, now()->addHours(8)); + Cache::increment($prefix.':duration_total_ms', $durationMs); + + $latencyWarningMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000)); + if ($durationMs >= $latencyWarningMs) { + Log::warning('AI provider latency warning', [ + 'tenant_id' => $request->tenant_id, + 'event_id' => $request->event_id, + 'request_id' => $request->id, + 'duration_ms' => $durationMs, + 'threshold_ms' => $latencyWarningMs, + 'stage' => $stage, + ]); + } + } + + $this->checkFailureRateAlert($request, $prefix, $total, $stage); + } + + private function checkFailureRateAlert(AiEditRequest $request, string $prefix, int $total, string $stage): void + { + $minSamples = max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)); + if ($total < $minSamples) { + return; + } + + $failed = (int) (Cache::get($prefix.':failed', 0) ?: 0); + $threshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35); + $failureRate = $total > 0 ? ($failed / $total) : 0.0; + + if ($failureRate < $threshold) { + return; + } + + $cooldownKey = $prefix.':failure_rate_alert'; + if (! Cache::add($cooldownKey, 1, now()->addMinutes(30))) { + return; + } + + Log::warning('AI failure-rate alert threshold reached', [ + 'tenant_id' => $request->tenant_id, + 'event_id' => $request->event_id, + 'failure_rate' => round($failureRate, 5), + 'failed' => $failed, + 'total' => $total, + 'threshold' => $threshold, + 'stage' => $stage, + ]); + } +} diff --git a/app/Services/AiEditing/AiStatusNotificationService.php b/app/Services/AiEditing/AiStatusNotificationService.php new file mode 100644 index 00000000..4e142a03 --- /dev/null +++ b/app/Services/AiEditing/AiStatusNotificationService.php @@ -0,0 +1,164 @@ +status; + if (! in_array($status, [ + AiEditRequest::STATUS_SUCCEEDED, + AiEditRequest::STATUS_FAILED, + AiEditRequest::STATUS_BLOCKED, + ], true)) { + return; + } + + if (! $this->claimLock((int) $request->id, $status)) { + return; + } + + $event = Event::query() + ->with('tenant.user') + ->find((int) $request->event_id); + + if (! $event || ! $event->tenant) { + return; + } + + $request->loadMissing('style'); + + $this->notifyTenant($event->tenant, $event, $request, $status); + + if ((int) ($request->requested_by_user_id ?? 0) === 0) { + $this->notifyGuest($event, $request, $status); + } + } + + private function claimLock(int $requestId, string $status): bool + { + $key = sprintf('ai-editing:terminal-notification:request:%d:status:%s', $requestId, $status); + + return Cache::add($key, 1, now()->addDays(7)); + } + + private function notifyGuest(Event $event, AiEditRequest $request, string $status): void + { + [$title, $body] = match ($status) { + AiEditRequest::STATUS_SUCCEEDED => [ + 'Dein AI-Magic-Edit ist fertig ✨', + 'Dein bearbeitetes Foto ist jetzt verfügbar.', + ], + AiEditRequest::STATUS_BLOCKED => [ + 'AI-Magic-Edit wurde blockiert', + 'Die Bearbeitung wurde durch die Sicherheitsregeln gestoppt.', + ], + default => [ + 'AI-Magic-Edit fehlgeschlagen', + 'Die Bearbeitung konnte nicht abgeschlossen werden. Bitte erneut versuchen.', + ], + }; + + $options = [ + 'payload' => [ + 'photo_id' => (int) $request->photo_id, + 'count' => 1, + ], + 'priority' => $status === AiEditRequest::STATUS_SUCCEEDED ? 2 : 3, + 'expires_at' => now()->addHours(6), + 'audience_scope' => GuestNotificationAudience::ALL, + ]; + + $deviceId = trim((string) ($request->requested_by_device_id ?? '')); + if ($deviceId !== '') { + $options['audience_scope'] = GuestNotificationAudience::GUEST; + $options['target_identifier'] = $deviceId; + } + + $this->guestNotifications->createNotification( + $event, + GuestNotificationType::UPLOAD_ALERT, + $title, + $body, + $options + ); + } + + private function notifyTenant(Tenant $tenant, Event $event, AiEditRequest $request, string $status): void + { + $type = match ($status) { + AiEditRequest::STATUS_SUCCEEDED => 'ai_edit_succeeded', + AiEditRequest::STATUS_BLOCKED => 'ai_edit_blocked', + default => 'ai_edit_failed', + }; + + $log = $this->tenantNotificationLogger->log($tenant, [ + 'type' => $type, + 'channel' => 'system', + 'status' => 'sent', + 'sent_at' => now(), + 'context' => [ + 'scope' => 'ai', + 'status' => $status, + 'event_id' => (int) $event->id, + 'event_slug' => (string) $event->slug, + 'event_name' => $this->resolveEventName($event->name), + 'request_id' => (int) $request->id, + 'photo_id' => (int) $request->photo_id, + 'style_key' => $request->style?->key, + 'style_name' => $request->style?->name, + 'failure_code' => $request->failure_code, + ], + ]); + + $this->createReceipt($tenant, (int) $log->id); + } + + private function createReceipt(Tenant $tenant, int $logId): void + { + $userId = (int) ($tenant->user_id ?? 0); + if ($userId <= 0) { + return; + } + + TenantNotificationReceipt::query()->create([ + 'tenant_id' => (int) $tenant->id, + 'notification_log_id' => $logId, + 'user_id' => $userId, + 'status' => 'delivered', + ]); + } + + private function resolveEventName(mixed $name): ?string + { + if (is_string($name) && trim($name) !== '') { + return trim($name); + } + + if (is_array($name)) { + foreach ($name as $candidate) { + if (is_string($candidate) && trim($candidate) !== '') { + return trim($candidate); + } + } + } + + return null; + } +} diff --git a/app/Services/AiEditing/Safety/AiAbuseEscalationService.php b/app/Services/AiEditing/Safety/AiAbuseEscalationService.php new file mode 100644 index 00000000..b238e1cc --- /dev/null +++ b/app/Services/AiEditing/Safety/AiAbuseEscalationService.php @@ -0,0 +1,64 @@ +record('prompt_block', $tenantId, $eventId, $scope); + } + + /** + * @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string} + */ + public function recordOutputBlock(int $tenantId, int $eventId, string $scope): array + { + return $this->record('output_block', $tenantId, $eventId, $scope); + } + + /** + * @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string} + */ + private function record(string $type, int $tenantId, int $eventId, string $scope): array + { + $threshold = max(1, (int) config('ai-editing.abuse.escalation_threshold_per_hour', 25)); + $cooldownMinutes = max(1, (int) config('ai-editing.abuse.escalation_cooldown_minutes', 30)); + $bucket = now()->format('YmdH'); + + $counterKey = sprintf('ai-editing:abuse:%s:tenant:%d:event:%d:hour:%s', $type, $tenantId, $eventId, $bucket); + Cache::add($counterKey, 0, now()->addHours(2)); + $count = (int) Cache::increment($counterKey); + + $escalated = $count >= $threshold; + if ($escalated) { + $cooldownKey = sprintf('ai-editing:abuse:%s:tenant:%d:event:%d:cooldown', $type, $tenantId, $eventId); + if (Cache::add($cooldownKey, 1, now()->addMinutes($cooldownMinutes))) { + Log::warning('AI abuse escalation threshold reached', [ + 'tenant_id' => $tenantId, + 'event_id' => $eventId, + 'type' => $type, + 'count' => $count, + 'threshold' => $threshold, + 'scope_hash' => hash('sha256', $scope), + ]); + } + } + + return [ + 'type' => $type, + 'count' => $count, + 'threshold' => $threshold, + 'escalated' => $escalated, + 'reason_code' => $escalated ? self::REASON_CODE : null, + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index af5a73f2..29f82707 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -30,6 +30,8 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\MonitorStorageCommand::class, \App\Console\Commands\DispatchStorageArchiveCommand::class, \App\Console\Commands\CheckUploadQueuesCommand::class, + \App\Console\Commands\AiEditsRecoverStuckCommand::class, + \App\Console\Commands\AiEditsPruneCommand::class, \App\Console\Commands\PurgeExpiredDataExports::class, \App\Console\Commands\ProcessTenantRetention::class, \App\Console\Commands\SendGuestFeedbackReminders::class, @@ -65,6 +67,10 @@ return Application::configure(basePath: dirname(__DIR__)) ->dailyAt('01:00') ->withoutOverlapping() ->onFailure($onFailure('storage:archive-pending')); + $schedule->command('ai-edits:prune') + ->dailyAt('02:30') + ->withoutOverlapping() + ->onFailure($onFailure('ai-edits:prune')); $schedule->command('photobooth:cleanup-expired') ->hourly() ->withoutOverlapping() diff --git a/config/ai-editing.php b/config/ai-editing.php index 0c9bf273..be6aafb7 100644 --- a/config/ai-editing.php +++ b/config/ai-editing.php @@ -24,10 +24,14 @@ return [ 'abuse' => [ 'guest_submit_per_minute' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_MINUTE', 8), 'guest_submit_per_hour' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_HOUR', 40), + 'guest_submit_per_event_per_minute' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_EVENT_PER_MINUTE', 40), 'guest_status_per_minute' => (int) env('AI_EDITING_GUEST_STATUS_PER_MINUTE', 60), 'tenant_submit_per_minute' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_MINUTE', 30), 'tenant_submit_per_hour' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_HOUR', 240), + 'tenant_submit_per_event_per_minute' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_EVENT_PER_MINUTE', 120), 'tenant_status_per_minute' => (int) env('AI_EDITING_TENANT_STATUS_PER_MINUTE', 120), + 'escalation_threshold_per_hour' => (int) env('AI_EDITING_ESCALATION_THRESHOLD_PER_HOUR', 25), + 'escalation_cooldown_minutes' => (int) env('AI_EDITING_ESCALATION_COOLDOWN_MINUTES', 30), ], 'queue' => [ @@ -38,6 +42,17 @@ return [ 'billing' => [ 'default_unit_cost_usd' => (float) env('AI_EDITING_DEFAULT_UNIT_COST_USD', 0.01), + 'budget' => [ + 'soft_cap_usd' => env('AI_EDITING_BUDGET_SOFT_CAP_USD'), + 'hard_cap_usd' => env('AI_EDITING_BUDGET_HARD_CAP_USD'), + 'hard_stop_enabled' => (bool) env('AI_EDITING_BUDGET_HARD_STOP_ENABLED', true), + 'alert_cooldown_minutes' => (int) env('AI_EDITING_BUDGET_ALERT_COOLDOWN_MINUTES', 30), + ], + ], + + 'retention' => [ + 'request_days' => (int) env('AI_EDITING_REQUEST_RETENTION_DAYS', 90), + 'usage_ledger_days' => (int) env('AI_EDITING_USAGE_LEDGER_RETENTION_DAYS', 365), ], 'providers' => [ @@ -45,4 +60,10 @@ return [ 'mode' => env('AI_EDITING_RUNWARE_MODE', 'live'), ], ], + + 'observability' => [ + 'failure_rate_alert_threshold' => (float) env('AI_EDITING_FAILURE_RATE_ALERT_THRESHOLD', 0.35), + 'failure_rate_min_samples' => (int) env('AI_EDITING_FAILURE_RATE_MIN_SAMPLES', 10), + 'latency_warning_ms' => (int) env('AI_EDITING_LATENCY_WARNING_MS', 15000), + ], ]; diff --git a/database/migrations/2026_02_06_213700_add_version_to_ai_styles_table.php b/database/migrations/2026_02_06_213700_add_version_to_ai_styles_table.php new file mode 100644 index 00000000..7f06bf2e --- /dev/null +++ b/database/migrations/2026_02_06_213700_add_version_to_ai_styles_table.php @@ -0,0 +1,24 @@ +unsignedInteger('version')->default(1)->after('name'); + $table->index(['is_active', 'version']); + }); + } + + public function down(): void + { + Schema::table('ai_styles', function (Blueprint $table) { + $table->dropIndex(['is_active', 'version']); + $table->dropColumn('version'); + }); + } +}; diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index ba93c811..2130ecc4 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -337,6 +337,27 @@ "title": "Gäste-Nutzung Warnung", "body": "{{event}} liegt bei {{used}} / {{limit}} Gästen." }, + "aiEditSucceeded": { + "title": "AI-Edit abgeschlossen", + "body": "{{event}} hat ein fertiges AI-Edit." + }, + "aiEditFailed": { + "title": "AI-Edit fehlgeschlagen", + "body": "{{event}} konnte ein AI-Edit nicht abschließen. Grund: {{reason}}.", + "reasonUnknown": "unbekannt" + }, + "aiEditBlocked": { + "title": "AI-Edit blockiert", + "body": "Ein AI-Edit für {{event}} wurde durch Sicherheitsprüfungen blockiert." + }, + "aiBudgetSoftCap": { + "title": "AI-Budget Warnung", + "body": "{{event}} hat {{spend}} USD von {{cap}} USD AI-Budget erreicht." + }, + "aiBudgetHardCap": { + "title": "AI-Budget ausgeschöpft", + "body": "{{event}} hat das harte AI-Budget-Limit von {{cap}} USD erreicht." + }, "generic": { "body": "Benachrichtigung über {{channel}}." }, @@ -354,6 +375,7 @@ "gallery": "Galerie", "events": "Events", "package": "Paket", + "ai": "AI", "general": "Allgemein" }, "markAllRead": "Alle als gelesen markieren", diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 3132cc7f..aa6a3993 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -333,6 +333,27 @@ "title": "Guest usage warning", "body": "{{event}} is at {{used}} / {{limit}} guests." }, + "aiEditSucceeded": { + "title": "AI edit completed", + "body": "{{event}} has a completed AI edit ready." + }, + "aiEditFailed": { + "title": "AI edit failed", + "body": "{{event}} could not finish an AI edit. Reason: {{reason}}.", + "reasonUnknown": "unknown" + }, + "aiEditBlocked": { + "title": "AI edit blocked", + "body": "{{event}} AI edit was blocked by safety checks." + }, + "aiBudgetSoftCap": { + "title": "AI budget warning", + "body": "{{event}} reached {{spend}} USD of {{cap}} USD AI budget." + }, + "aiBudgetHardCap": { + "title": "AI budget exhausted", + "body": "{{event}} reached the hard AI budget cap of {{cap}} USD." + }, "generic": { "body": "Notification sent via {{channel}}." }, @@ -350,6 +371,7 @@ "gallery": "Gallery", "events": "Events", "package": "Package", + "ai": "AI", "general": "General" }, "markAllRead": "Mark all read", diff --git a/resources/js/admin/mobile/NotificationsPage.tsx b/resources/js/admin/mobile/NotificationsPage.tsx index 4c54744e..bea57877 100644 --- a/resources/js/admin/mobile/NotificationsPage.tsx +++ b/resources/js/admin/mobile/NotificationsPage.tsx @@ -144,6 +144,10 @@ function formatLog( const used = typeof ctx.used === 'number' ? ctx.used : null; const remaining = typeof ctx.remaining === 'number' ? ctx.remaining : null; const days = typeof ctx.day === 'number' ? ctx.day : null; + const spendUsd = typeof ctx.spend_usd === 'number' ? ctx.spend_usd : null; + const softCapUsd = typeof ctx.soft_cap_usd === 'number' ? ctx.soft_cap_usd : null; + const hardCapUsd = typeof ctx.hard_cap_usd === 'number' ? ctx.hard_cap_usd : null; + const failureCode = typeof ctx.failure_code === 'string' ? ctx.failure_code : null; const ctxEventId = ctx.event_id ?? ctx.eventId; const eventId = typeof ctxEventId === 'string' ? Number(ctxEventId) : (typeof ctxEventId === 'number' ? ctxEventId : null); const name = eventName ?? t('mobileNotifications.unknownEvent', 'Event'); @@ -165,6 +169,12 @@ function formatLog( case 'package_expiring': case 'package_expired': return 'package'; + case 'ai_edit_succeeded': + case 'ai_edit_failed': + case 'ai_edit_blocked': + case 'ai_budget_soft_cap': + case 'ai_budget_hard_cap': + return 'ai'; default: return 'general'; } @@ -276,6 +286,80 @@ function formatLog( is_read: isRead, scope, }; + case 'ai_edit_succeeded': + return { + id: String(log.id), + title: t('notificationLogs.aiEditSucceeded.title', 'AI edit completed'), + body: t('notificationLogs.aiEditSucceeded.body', '{{event}} has a completed AI edit ready.', { + event: name, + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'info', + eventId, + eventName, + is_read: isRead, + scope, + }; + case 'ai_edit_failed': + return { + id: String(log.id), + title: t('notificationLogs.aiEditFailed.title', 'AI edit failed'), + body: t('notificationLogs.aiEditFailed.body', '{{event}} could not finish an AI edit. Reason: {{reason}}.', { + event: name, + reason: failureCode ?? t('notificationLogs.aiEditFailed.reasonUnknown', 'unknown'), + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + eventName, + is_read: isRead, + scope, + }; + case 'ai_edit_blocked': + return { + id: String(log.id), + title: t('notificationLogs.aiEditBlocked.title', 'AI edit blocked'), + body: t('notificationLogs.aiEditBlocked.body', '{{event}} AI edit was blocked by safety checks.', { + event: name, + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + eventName, + is_read: isRead, + scope, + }; + case 'ai_budget_soft_cap': + return { + id: String(log.id), + title: t('notificationLogs.aiBudgetSoftCap.title', 'AI budget warning'), + body: t('notificationLogs.aiBudgetSoftCap.body', '{{event}} reached {{spend}} USD of {{cap}} USD AI budget.', { + event: name, + spend: spendUsd ?? '—', + cap: softCapUsd ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'info', + eventId, + eventName, + is_read: isRead, + scope, + }; + case 'ai_budget_hard_cap': + return { + id: String(log.id), + title: t('notificationLogs.aiBudgetHardCap.title', 'AI budget exhausted'), + body: t('notificationLogs.aiBudgetHardCap.body', '{{event}} reached the hard AI budget cap of {{cap}} USD.', { + event: name, + cap: hardCapUsd ?? '—', + }), + time: log.sent_at ?? log.failed_at ?? '', + tone: 'warning', + eventId, + eventName, + is_read: isRead, + scope, + }; default: return { id: String(log.id), @@ -379,6 +463,16 @@ export default function MobileNotificationsPage() { }; }, [reload]); + React.useEffect(() => { + const interval = window.setInterval(() => { + void reload(); + }, 90_000); + + return () => { + window.clearInterval(interval); + }; + }, [reload]); + React.useEffect(() => { (async () => { try { @@ -588,6 +682,7 @@ export default function MobileNotificationsPage() { { key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') }, { key: 'events', label: t('notificationLogs.scope.events', 'Events') }, { key: 'package', label: t('notificationLogs.scope.package', 'Package') }, + { key: 'ai', label: t('notificationLogs.scope.ai', 'AI') }, { key: 'general', label: t('notificationLogs.scope.general', 'General') }, ] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => { const isActive = (scopeParam ?? 'all') === filter.key; diff --git a/resources/js/admin/mobile/hooks/useNotificationsBadge.ts b/resources/js/admin/mobile/hooks/useNotificationsBadge.ts index c4b3fd74..2d61586a 100644 --- a/resources/js/admin/mobile/hooks/useNotificationsBadge.ts +++ b/resources/js/admin/mobile/hooks/useNotificationsBadge.ts @@ -10,6 +10,8 @@ export function useNotificationsBadge() { const { data: count = 0 } = useQuery({ queryKey: ['mobile', 'notifications', 'badge', 'tenant'], staleTime: 60_000, + refetchInterval: 90_000, + refetchIntervalInBackground: true, queryFn: async () => { const logs = await listNotificationLogs({ perPage: 1 }); const meta: any = logs.meta ?? {}; diff --git a/resources/js/admin/mobile/lib/notificationGrouping.test.ts b/resources/js/admin/mobile/lib/notificationGrouping.test.ts index 85e89c90..7e392821 100644 --- a/resources/js/admin/mobile/lib/notificationGrouping.test.ts +++ b/resources/js/admin/mobile/lib/notificationGrouping.test.ts @@ -30,4 +30,14 @@ describe('groupNotificationsByScope', () => { const grouped = groupNotificationsByScope(items); expect(grouped.map((group) => group.scope)).toEqual(['photos', 'events', 'general']); }); + + it('places ai scope before general', () => { + const items: Item[] = [ + { id: '1', scope: 'general', is_read: true }, + { id: '2', scope: 'ai', is_read: true }, + ]; + + const grouped = groupNotificationsByScope(items); + expect(grouped.map((group) => group.scope)).toEqual(['ai', 'general']); + }); }); diff --git a/resources/js/admin/mobile/lib/notificationGrouping.ts b/resources/js/admin/mobile/lib/notificationGrouping.ts index 7fc386f5..99f115f3 100644 --- a/resources/js/admin/mobile/lib/notificationGrouping.ts +++ b/resources/js/admin/mobile/lib/notificationGrouping.ts @@ -1,4 +1,4 @@ -export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general'; +export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'ai' | 'general'; export type ScopedNotification = { scope: NotificationScope; @@ -17,6 +17,7 @@ const SCOPE_ORDER: NotificationScope[] = [ 'gallery', 'events', 'package', + 'ai', 'general', ]; diff --git a/tests/Feature/Ai/AiEditingDataModelTest.php b/tests/Feature/Ai/AiEditingDataModelTest.php index 6181a09c..3bb8f3ea 100644 --- a/tests/Feature/Ai/AiEditingDataModelTest.php +++ b/tests/Feature/Ai/AiEditingDataModelTest.php @@ -29,6 +29,7 @@ class AiEditingDataModelTest extends TestCase 'key', 'provider', 'provider_model', + 'version', 'requires_source_image', 'is_premium', ] as $column) { diff --git a/tests/Feature/Api/Event/EventAiEditControllerTest.php b/tests/Feature/Api/Event/EventAiEditControllerTest.php index a08fac52..847cf11c 100644 --- a/tests/Feature/Api/Event/EventAiEditControllerTest.php +++ b/tests/Feature/Api/Event/EventAiEditControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Event; use App\Models\AiEditingSetting; use App\Models\AiEditRequest; use App\Models\AiStyle; +use App\Models\AiUsageLedger; use App\Models\Event; use App\Models\EventPackage; use App\Models\EventPackageAddon; @@ -302,6 +303,7 @@ class EventAiEditControllerTest extends TestCase $response->assertOk() ->assertJsonPath('data.0.id', $allowed->id) + ->assertJsonPath('data.0.version', 1) ->assertJsonCount(1, 'data') ->assertJsonPath('meta.required_feature', 'ai_styling') ->assertJsonPath('meta.allow_custom_prompt', false) @@ -594,6 +596,129 @@ class EventAiEditControllerTest extends TestCase $second->assertStatus(429); } + public function test_guest_submit_endpoint_enforces_event_scope_rate_limit_across_devices(): void + { + config([ + 'ai-editing.abuse.guest_submit_per_minute' => 10, + 'ai-editing.abuse.guest_submit_per_hour' => 100, + 'ai-editing.abuse.guest_submit_per_event_per_minute' => 1, + ]); + + $event = Event::factory()->create([ + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest-ai-event-rate-limit']) + ->getAttribute('plain_token'); + + $first = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-a']) + ->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [ + 'prompt' => 'First request.', + 'idempotency_key' => 'guest-event-rate-limit-1', + ]); + $first->assertCreated(); + + $second = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-b']) + ->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [ + 'prompt' => 'Second request.', + 'idempotency_key' => 'guest-event-rate-limit-2', + ]); + $second->assertStatus(429); + } + + public function test_guest_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void + { + $event = Event::factory()->create([ + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $tenantSettings = (array) ($event->tenant->settings ?? []); + data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01); + $event->tenant->update(['settings' => $tenantSettings]); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + AiUsageLedger::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'entry_type' => AiUsageLedger::TYPE_DEBIT, + 'quantity' => 1, + 'unit_cost_usd' => 0.02, + 'amount_usd' => 0.02, + 'currency' => 'USD', + 'recorded_at' => now(), + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest-ai-budget-hard-cap']) + ->getAttribute('plain_token'); + + $response = $this->withHeaders(['X-Device-Id' => 'guest-device-budget-cap']) + ->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [ + 'prompt' => 'Stylize image.', + 'idempotency_key' => 'guest-budget-cap-1', + ]); + + $response->assertForbidden() + ->assertJsonPath('error.code', 'budget_hard_cap_reached') + ->assertJsonPath('error.meta.budget.hard_reached', true); + } + + public function test_guest_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void + { + config([ + 'ai-editing.abuse.escalation_threshold_per_hour' => 1, + 'ai-editing.abuse.escalation_cooldown_minutes' => 1, + ]); + AiEditingSetting::flushCache(); + AiEditingSetting::query()->create(array_merge( + AiEditingSetting::defaults(), + ['blocked_terms' => ['explicit']] + )); + + $event = Event::factory()->create([ + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $token = app(EventJoinTokenService::class) + ->createToken($event, ['label' => 'guest-ai-escalation']) + ->getAttribute('plain_token'); + + $response = $this->withHeaders(['X-Device-Id' => 'guest-device-escalation']) + ->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [ + 'prompt' => 'Create an explicit style image.', + 'idempotency_key' => 'guest-escalation-1', + ]); + + $response->assertCreated() + ->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED) + ->assertJsonFragment(['abuse_escalation_threshold_reached']); + + $requestId = (int) $response->json('data.id'); + $editRequest = AiEditRequest::query()->find($requestId); + + $this->assertNotNull($editRequest); + $this->assertIsArray($editRequest?->metadata); + $this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null); + $this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false)); + } + public function test_guest_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void { $event = Event::factory()->create([ diff --git a/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php b/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php index bfae5b8f..24b76904 100644 --- a/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php +++ b/tests/Feature/Api/Tenant/TenantAiEditControllerTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Tenant; use App\Models\AiEditingSetting; use App\Models\AiEditRequest; use App\Models\AiStyle; +use App\Models\AiUsageLedger; use App\Models\Event; use App\Models\EventPackage; use App\Models\EventPackageAddon; @@ -305,6 +306,7 @@ class TenantAiEditControllerTest extends TenantTestCase $response->assertOk() ->assertJsonPath('data.0.id', $active->id) + ->assertJsonPath('data.0.version', 1) ->assertJsonCount(1, 'data') ->assertJsonPath('meta.required_feature', 'ai_styling') ->assertJsonPath('meta.event_enabled', false) @@ -540,6 +542,148 @@ class TenantAiEditControllerTest extends TenantTestCase $second->assertStatus(429); } + public function test_tenant_submit_endpoint_enforces_event_scope_rate_limit(): void + { + config([ + 'ai-editing.abuse.tenant_submit_per_minute' => 10, + 'ai-editing.abuse.tenant_submit_per_hour' => 100, + 'ai-editing.abuse.tenant_submit_per_event_per_minute' => 1, + ]); + + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'tenant-event-rate-limit-style', + 'name' => 'Tenant Event Rate Limit', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $first = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'prompt' => 'First request.', + 'idempotency_key' => 'tenant-event-rate-limit-1', + ]); + $first->assertCreated(); + + $second = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'prompt' => 'Second request.', + 'idempotency_key' => 'tenant-event-rate-limit-2', + ]); + $second->assertStatus(429); + } + + public function test_tenant_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void + { + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $tenantSettings = (array) ($this->tenant->settings ?? []); + data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01); + $this->tenant->update(['settings' => $tenantSettings]); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'tenant-budget-cap-style', + 'name' => 'Tenant Budget Cap', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + AiUsageLedger::query()->create([ + 'tenant_id' => $this->tenant->id, + 'event_id' => $event->id, + 'entry_type' => AiUsageLedger::TYPE_DEBIT, + 'quantity' => 1, + 'unit_cost_usd' => 0.02, + 'amount_usd' => 0.02, + 'currency' => 'USD', + 'recorded_at' => now(), + ]); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'prompt' => 'Apply AI style.', + 'idempotency_key' => 'tenant-budget-cap-1', + ]); + + $response->assertForbidden() + ->assertJsonPath('error.code', 'budget_hard_cap_reached') + ->assertJsonPath('error.meta.budget.hard_reached', true); + } + + public function test_tenant_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void + { + config([ + 'ai-editing.abuse.escalation_threshold_per_hour' => 1, + 'ai-editing.abuse.escalation_cooldown_minutes' => 1, + ]); + AiEditingSetting::flushCache(); + AiEditingSetting::query()->create(array_merge( + AiEditingSetting::defaults(), + ['blocked_terms' => ['weapon']] + )); + + $event = Event::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'published', + ]); + $this->attachEntitledEventPackage($event); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $this->tenant->id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'tenant-escalation-style', + 'name' => 'Tenant Escalation', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [ + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'prompt' => 'Add weapon effects.', + 'idempotency_key' => 'tenant-escalation-1', + ]); + + $response->assertCreated() + ->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED) + ->assertJsonFragment(['abuse_escalation_threshold_reached']); + + $requestId = (int) $response->json('data.id'); + $editRequest = AiEditRequest::query()->find($requestId); + + $this->assertNotNull($editRequest); + $this->assertIsArray($editRequest?->metadata); + $this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null); + $this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false)); + } + public function test_tenant_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void { $event = Event::factory()->create([ @@ -685,7 +829,10 @@ class TenantAiEditControllerTest extends TenantTestCase ->assertJsonPath('data.total', 2) ->assertJsonPath('data.status_counts.succeeded', 1) ->assertJsonPath('data.status_counts.failed', 1) - ->assertJsonPath('data.failed_total', 1); + ->assertJsonPath('data.failed_total', 1) + ->assertJsonPath('data.usage.debit_count', 0) + ->assertJsonPath('data.observability.provider_runs_total', 0) + ->assertJsonPath('data.budget.hard_stop_enabled', true); } private function attachEntitledEventPackage(Event $event): EventPackage diff --git a/tests/Feature/Console/AiEditsPruneCommandTest.php b/tests/Feature/Console/AiEditsPruneCommandTest.php new file mode 100644 index 00000000..2444cbbb --- /dev/null +++ b/tests/Feature/Console/AiEditsPruneCommandTest.php @@ -0,0 +1,172 @@ + 90, + 'ai-editing.retention.usage_ledger_days' => 365, + ]); + + [$oldRequest, $recentRequest] = $this->createRequestsForPruning(); + [$oldLedger, $recentLedger] = $this->createLedgerEntriesForPruning($oldRequest, $recentRequest); + + $this->artisan('ai-edits:prune') + ->expectsOutputToContain('AI prune candidates') + ->expectsOutputToContain('Pruned AI data') + ->assertExitCode(0); + + $this->assertDatabaseMissing('ai_edit_requests', ['id' => $oldRequest->id]); + $this->assertDatabaseMissing('ai_provider_runs', ['request_id' => $oldRequest->id]); + $this->assertDatabaseMissing('ai_edit_outputs', ['request_id' => $oldRequest->id]); + $this->assertDatabaseHas('ai_edit_requests', ['id' => $recentRequest->id]); + + $this->assertDatabaseMissing('ai_usage_ledgers', ['id' => $oldLedger->id]); + $this->assertNotNull($recentLedger); + if ($recentLedger) { + $this->assertDatabaseHas('ai_usage_ledgers', ['id' => $recentLedger->id]); + } + } + + public function test_command_pretend_mode_does_not_delete_records(): void + { + config([ + 'ai-editing.retention.request_days' => 90, + 'ai-editing.retention.usage_ledger_days' => 365, + ]); + + [$oldRequest] = $this->createRequestsForPruning(); + [$oldLedger] = $this->createLedgerEntriesForPruning($oldRequest, null); + + $this->artisan('ai-edits:prune', ['--pretend' => true]) + ->expectsOutputToContain('AI prune candidates') + ->expectsOutput('Pretend mode enabled. No records were deleted.') + ->assertExitCode(0); + + $this->assertDatabaseHas('ai_edit_requests', ['id' => $oldRequest->id]); + $this->assertDatabaseHas('ai_usage_ledgers', ['id' => $oldLedger->id]); + } + + /** + * @return array{0: AiEditRequest, 1: AiEditRequest} + */ + private function createRequestsForPruning(): array + { + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'prune-style', + 'name' => 'Prune Style', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $oldRequest = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_SUCCEEDED, + 'safety_state' => 'passed', + 'prompt' => 'Old request', + 'idempotency_key' => 'old-prune-request', + 'queued_at' => now()->subDays(121), + 'started_at' => now()->subDays(120), + 'completed_at' => now()->subDays(120), + 'expires_at' => now()->subDays(30), + ]); + + AiProviderRun::query()->create([ + 'request_id' => $oldRequest->id, + 'provider' => 'runware', + 'attempt' => 1, + 'provider_task_id' => 'old-task-id', + 'status' => AiProviderRun::STATUS_SUCCEEDED, + 'started_at' => now()->subDays(120), + 'finished_at' => now()->subDays(120), + ]); + AiEditOutput::query()->create([ + 'request_id' => $oldRequest->id, + 'provider_asset_id' => 'old-asset-id', + 'provider_url' => 'https://cdn.example.invalid/old.jpg', + 'safety_state' => 'passed', + 'generated_at' => now()->subDays(120), + ]); + + $recentRequest = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_SUCCEEDED, + 'safety_state' => 'passed', + 'prompt' => 'Recent request', + 'idempotency_key' => 'recent-prune-request', + 'queued_at' => now()->subDays(11), + 'started_at' => now()->subDays(10), + 'completed_at' => now()->subDays(10), + ]); + + return [$oldRequest, $recentRequest]; + } + + /** + * @return array{0: AiUsageLedger, 1: ?AiUsageLedger} + */ + private function createLedgerEntriesForPruning(AiEditRequest $oldRequest, ?AiEditRequest $recentRequest): array + { + $oldLedger = AiUsageLedger::query()->create([ + 'tenant_id' => $oldRequest->tenant_id, + 'event_id' => $oldRequest->event_id, + 'request_id' => $oldRequest->id, + 'entry_type' => AiUsageLedger::TYPE_DEBIT, + 'quantity' => 1, + 'unit_cost_usd' => 0.01, + 'amount_usd' => 0.01, + 'currency' => 'USD', + 'recorded_at' => now()->subDays(400), + ]); + + if (! $recentRequest) { + return [$oldLedger, null]; + } + + $recentLedger = AiUsageLedger::query()->create([ + 'tenant_id' => $recentRequest->tenant_id, + 'event_id' => $recentRequest->event_id, + 'request_id' => $recentRequest->id, + 'entry_type' => AiUsageLedger::TYPE_DEBIT, + 'quantity' => 1, + 'unit_cost_usd' => 0.01, + 'amount_usd' => 0.01, + 'currency' => 'USD', + 'recorded_at' => now()->subDays(30), + ]); + + return [$oldLedger, $recentLedger]; + } +} diff --git a/tests/Feature/Console/AiEditsRecoverStuckCommandTest.php b/tests/Feature/Console/AiEditsRecoverStuckCommandTest.php new file mode 100644 index 00000000..3d444684 --- /dev/null +++ b/tests/Feature/Console/AiEditsRecoverStuckCommandTest.php @@ -0,0 +1,125 @@ +createStuckRequests(); + + Queue::fake(); + + $this->artisan('ai-edits:recover-stuck', [ + '--minutes' => 10, + '--requeue' => true, + ])->assertExitCode(0); + + Queue::assertPushed(ProcessAiEditRequest::class, 1); + Queue::assertPushed(PollAiEditRequest::class, 1); + $this->assertDatabaseHas('ai_edit_requests', [ + 'id' => $queuedRequest->id, + 'status' => AiEditRequest::STATUS_QUEUED, + ]); + $this->assertDatabaseHas('ai_edit_requests', [ + 'id' => $processingRequest->id, + 'status' => AiEditRequest::STATUS_PROCESSING, + ]); + } + + public function test_command_can_mark_stuck_requests_as_failed(): void + { + [$queuedRequest, $processingRequest] = $this->createStuckRequests(); + + $this->artisan('ai-edits:recover-stuck', [ + '--minutes' => 10, + '--fail' => true, + ])->assertExitCode(0); + + $this->assertDatabaseHas('ai_edit_requests', [ + 'id' => $queuedRequest->id, + 'status' => AiEditRequest::STATUS_FAILED, + 'failure_code' => 'operator_recovery_marked_failed', + ]); + $this->assertDatabaseHas('ai_edit_requests', [ + 'id' => $processingRequest->id, + 'status' => AiEditRequest::STATUS_FAILED, + 'failure_code' => 'operator_recovery_marked_failed', + ]); + } + + /** + * @return array{0: AiEditRequest, 1: AiEditRequest} + */ + private function createStuckRequests(): array + { + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'recovery-style', + 'name' => 'Recovery Style', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $queued = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_QUEUED, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'stuck-queued-1', + 'queued_at' => now()->subMinutes(45), + 'updated_at' => now()->subMinutes(45), + ]); + + $processing = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'stuck-processing-1', + 'queued_at' => now()->subMinutes(50), + 'started_at' => now()->subMinutes(40), + 'updated_at' => now()->subMinutes(40), + ]); + + AiProviderRun::query()->create([ + 'request_id' => $processing->id, + 'provider' => 'runware', + 'attempt' => 1, + 'provider_task_id' => 'runware-task-recovery-1', + 'status' => AiProviderRun::STATUS_RUNNING, + 'started_at' => now()->subMinutes(39), + ]); + + return [$queued, $processing]; + } +} diff --git a/tests/Feature/Jobs/PollAiEditRequestTest.php b/tests/Feature/Jobs/PollAiEditRequestTest.php new file mode 100644 index 00000000..5bf59092 --- /dev/null +++ b/tests/Feature/Jobs/PollAiEditRequestTest.php @@ -0,0 +1,142 @@ +create(array_merge( + AiEditingSetting::defaults(), + [ + 'runware_mode' => 'live', + 'queue_auto_dispatch' => false, + 'queue_max_polls' => 1, + ] + )); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'poll-exhaust-style', + 'name' => 'Poll Exhaust', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'poll-exhaust-1', + 'queued_at' => now()->subMinutes(3), + 'started_at' => now()->subMinutes(2), + ]); + + $providerRun = AiProviderRun::query()->create([ + 'request_id' => $request->id, + 'provider' => 'runware', + 'attempt' => 1, + 'provider_task_id' => 'runware-task-1', + 'status' => AiProviderRun::STATUS_RUNNING, + 'started_at' => now()->subMinute(), + ]); + + $provider = \Mockery::mock(RunwareAiImageProvider::class); + $provider->shouldReceive('poll') + ->once() + ->withArgs(function (AiEditRequest $polledRequest, string $taskId): bool { + return $polledRequest->id > 0 && $taskId === 'runware-task-1'; + }) + ->andReturn(AiProviderResult::processing('runware-task-1')); + $this->app->instance(RunwareAiImageProvider::class, $provider); + + PollAiEditRequest::dispatchSync($request->id, 'runware-task-1', 1); + + $request->refresh(); + $providerRun->refresh(); + $latestRun = $request->providerRuns()->latest('attempt')->first(); + + $this->assertSame(AiEditRequest::STATUS_FAILED, $request->status); + $this->assertSame('provider_poll_timeout', $request->failure_code); + $this->assertNotNull($request->completed_at); + $this->assertNotNull($latestRun); + $this->assertSame(AiProviderRun::STATUS_FAILED, $latestRun?->status); + $this->assertSame('Polling exhausted after 1 attempt(s).', $latestRun?->error_message); + } + + public function test_poll_job_failed_hook_marks_request_as_failed(): void + { + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'poll-failed-hook-style', + 'name' => 'Poll Failed Hook', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'poll-failed-hook-1', + 'queued_at' => now()->subMinute(), + 'started_at' => now()->subSeconds(30), + ]); + + $job = new PollAiEditRequest($request->id, 'runware-task-2', 2); + $job->failed(new RuntimeException('Polling crashed')); + + $request->refresh(); + + $this->assertSame(AiEditRequest::STATUS_FAILED, $request->status); + $this->assertSame('queue_job_failed', $request->failure_code); + $this->assertSame('Polling crashed', $request->failure_message); + $this->assertNotNull($request->completed_at); + } +} diff --git a/tests/Feature/Jobs/ProcessAiEditRequestTest.php b/tests/Feature/Jobs/ProcessAiEditRequestTest.php index 96449fe5..25fc5363 100644 --- a/tests/Feature/Jobs/ProcessAiEditRequestTest.php +++ b/tests/Feature/Jobs/ProcessAiEditRequestTest.php @@ -9,7 +9,10 @@ use App\Models\AiStyle; use App\Models\AiUsageLedger; use App\Models\Event; use App\Models\Photo; +use App\Services\AiEditing\AiProviderResult; +use App\Services\AiEditing\Providers\RunwareAiImageProvider; use Illuminate\Foundation\Testing\RefreshDatabase; +use RuntimeException; use Tests\TestCase; class ProcessAiEditRequestTest extends TestCase @@ -187,5 +190,106 @@ class ProcessAiEditRequestTest extends TestCase $this->assertNotNull($request->completed_at); $this->assertSame(0, $request->outputs()->count()); $this->assertSame(1, $request->providerRuns()->count()); + $this->assertIsArray($request->metadata); + $this->assertSame('output_block', $request->metadata['abuse']['type'] ?? null); + } + + public function test_it_marks_request_failed_when_provider_returns_processing_without_task_id(): void + { + AiEditingSetting::query()->create(array_merge( + AiEditingSetting::defaults(), + [ + 'runware_mode' => 'live', + 'queue_auto_dispatch' => false, + ] + )); + + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'processing-no-task', + 'name' => 'Processing Without Task ID', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_QUEUED, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'job-processing-no-task', + 'queued_at' => now(), + ]); + + $provider = \Mockery::mock(RunwareAiImageProvider::class); + $provider->shouldReceive('submit') + ->once() + ->andReturn(new AiProviderResult(status: 'processing', providerTaskId: '')); + $this->app->instance(RunwareAiImageProvider::class, $provider); + + ProcessAiEditRequest::dispatchSync($request->id); + + $request->refresh(); + $run = $request->providerRuns()->first(); + + $this->assertSame(AiEditRequest::STATUS_FAILED, $request->status); + $this->assertSame('provider_task_id_missing', $request->failure_code); + $this->assertNotNull($request->completed_at); + $this->assertNotNull($run); + $this->assertSame('failed', $run?->status); + $this->assertSame('Provider returned processing state without task identifier.', $run?->error_message); + } + + public function test_process_job_failed_hook_marks_request_as_failed(): void + { + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'failed-hook-style', + 'name' => 'Failed Hook Style', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'requires_source_image' => true, + 'is_active' => true, + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => AiEditRequest::STATUS_PROCESSING, + 'safety_state' => 'pending', + 'prompt' => 'Transform image style.', + 'idempotency_key' => 'job-failed-hook-1', + 'queued_at' => now()->subMinute(), + 'started_at' => now()->subSeconds(30), + ]); + + $job = new ProcessAiEditRequest($request->id); + $job->failed(new RuntimeException('Queue worker timeout')); + + $request->refresh(); + + $this->assertSame(AiEditRequest::STATUS_FAILED, $request->status); + $this->assertSame('queue_job_failed', $request->failure_code); + $this->assertSame('Queue worker timeout', $request->failure_message); + $this->assertNotNull($request->completed_at); } } diff --git a/tests/Unit/Models/AiStyleVersioningTest.php b/tests/Unit/Models/AiStyleVersioningTest.php new file mode 100644 index 00000000..aa5b4901 --- /dev/null +++ b/tests/Unit/Models/AiStyleVersioningTest.php @@ -0,0 +1,46 @@ +create([ + 'key' => 'style-version-default', + 'name' => 'Version Default', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'is_active' => true, + ]); + + $this->assertSame(1, $style->version); + } + + public function test_style_version_increments_when_core_style_fields_change(): void + { + $style = AiStyle::query()->create([ + 'key' => 'style-version-increment', + 'name' => 'Version Increment', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'prompt_template' => 'Initial prompt', + 'is_active' => true, + ]); + + $this->assertSame(1, $style->version); + + $style->update([ + 'prompt_template' => 'Updated prompt', + ]); + + $style->refresh(); + $this->assertSame(2, $style->version); + } +} diff --git a/tests/Unit/Services/AiBudgetGuardServiceTest.php b/tests/Unit/Services/AiBudgetGuardServiceTest.php new file mode 100644 index 00000000..0827d7ee --- /dev/null +++ b/tests/Unit/Services/AiBudgetGuardServiceTest.php @@ -0,0 +1,126 @@ +create(['status' => 'published']); + + $settings = (array) ($event->tenant->settings ?? []); + data_set($settings, 'ai_editing.budget.soft_cap_usd', 10.0); + data_set($settings, 'ai_editing.budget.hard_cap_usd', 20.0); + $event->tenant->update(['settings' => $settings]); + + AiUsageLedger::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'entry_type' => AiUsageLedger::TYPE_DEBIT, + 'quantity' => 1, + 'unit_cost_usd' => 3.0, + 'amount_usd' => 3.0, + 'currency' => 'USD', + 'recorded_at' => now(), + ]); + + $decision = app(AiBudgetGuardService::class)->evaluateForEvent($event->fresh('tenant')); + + $this->assertTrue($decision['allowed']); + $this->assertFalse($decision['budget']['soft_reached']); + $this->assertFalse($decision['budget']['hard_reached']); + $this->assertSame(3.0, $decision['budget']['current_spend_usd']); + } + + public function test_it_blocks_requests_when_hard_cap_is_reached_without_override(): void + { + $event = Event::factory()->create(['status' => 'published']); + $owner = User::factory()->create(); + $event->tenant->update(['user_id' => $owner->id]); + + $settings = (array) ($event->tenant->settings ?? []); + data_set($settings, 'ai_editing.budget.hard_cap_usd', 5.0); + $event->tenant->update(['settings' => $settings]); + + AiUsageLedger::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'entry_type' => AiUsageLedger::TYPE_DEBIT, + 'quantity' => 1, + 'unit_cost_usd' => 5.0, + 'amount_usd' => 5.0, + 'currency' => 'USD', + 'recorded_at' => now(), + ]); + + $decision = app(AiBudgetGuardService::class)->evaluateForEvent($event->fresh('tenant')); + + $this->assertFalse($decision['allowed']); + $this->assertSame('budget_hard_cap_reached', $decision['reason_code']); + $this->assertTrue($decision['budget']['hard_reached']); + $this->assertFalse($decision['budget']['override_active']); + + $this->assertDatabaseHas('tenant_notification_logs', [ + 'tenant_id' => $event->tenant_id, + 'type' => 'ai_budget_hard_cap', + 'channel' => 'system', + 'status' => 'sent', + ]); + + $this->assertDatabaseHas('tenant_notification_receipts', [ + 'tenant_id' => $event->tenant_id, + 'user_id' => $owner->id, + 'status' => 'delivered', + ]); + } + + public function test_it_throttles_soft_cap_notifications_with_cooldown(): void + { + $event = Event::factory()->create(['status' => 'published']); + + $settings = (array) ($event->tenant->settings ?? []); + data_set($settings, 'ai_editing.budget.soft_cap_usd', 2.0); + data_set($settings, 'ai_editing.budget.hard_cap_usd', 100.0); + $event->tenant->update(['settings' => $settings]); + + AiUsageLedger::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'entry_type' => AiUsageLedger::TYPE_DEBIT, + 'quantity' => 1, + 'unit_cost_usd' => 3.0, + 'amount_usd' => 3.0, + 'currency' => 'USD', + 'recorded_at' => now(), + ]); + + $service = app(AiBudgetGuardService::class); + $service->evaluateForEvent($event->fresh('tenant')); + $service->evaluateForEvent($event->fresh('tenant')); + + $this->assertSame( + 1, + \App\Models\TenantNotificationLog::query() + ->where('tenant_id', $event->tenant_id) + ->where('type', 'ai_budget_soft_cap') + ->count() + ); + } +} diff --git a/tests/Unit/Services/AiObservabilityServiceTest.php b/tests/Unit/Services/AiObservabilityServiceTest.php new file mode 100644 index 00000000..d66e7c84 --- /dev/null +++ b/tests/Unit/Services/AiObservabilityServiceTest.php @@ -0,0 +1,96 @@ +makeRequest(AiEditRequest::STATUS_PROCESSING); + + app(AiObservabilityService::class)->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_SUCCEEDED, + 1200, + false, + 'process' + ); + + $bucket = now()->format('YmdH'); + $prefix = sprintf('ai-editing:obs:tenant:%d:event:%d:hour:%s', $request->tenant_id, $request->event_id, $bucket); + + $this->assertSame(1, (int) Cache::get($prefix.':total')); + $this->assertSame(1, (int) Cache::get($prefix.':succeeded')); + $this->assertSame(1200, (int) Cache::get($prefix.':duration_total_ms')); + } + + public function test_it_logs_failure_rate_alert_when_threshold_is_reached(): void + { + config([ + 'ai-editing.observability.failure_rate_alert_threshold' => 0.5, + 'ai-editing.observability.failure_rate_min_samples' => 1, + ]); + + $request = $this->makeRequest(AiEditRequest::STATUS_PROCESSING); + Log::spy(); + + app(AiObservabilityService::class)->recordTerminalOutcome( + $request, + AiEditRequest::STATUS_FAILED, + 500, + false, + 'poll' + ); + + Log::shouldHaveReceived('warning') + ->withArgs(function (string $message, array $context): bool { + return $message === 'AI failure-rate alert threshold reached' + && isset($context['failure_rate']) + && $context['failure_rate'] >= 0.5; + }) + ->once(); + } + + private function makeRequest(string $status): AiEditRequest + { + $event = Event::factory()->create(['status' => 'published']); + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + $style = AiStyle::query()->create([ + 'key' => 'obs-style', + 'name' => 'Observability Style', + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'is_active' => true, + ]); + + return AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'style_id' => $style->id, + 'provider' => 'runware', + 'provider_model' => 'runware-default', + 'status' => $status, + 'safety_state' => 'pending', + 'prompt' => 'Observability', + 'idempotency_key' => 'obs-'.uniqid('', true), + 'queued_at' => now()->subMinute(), + 'started_at' => now()->subSeconds(30), + ]); + } +} diff --git a/tests/Unit/Services/AiStatusNotificationServiceTest.php b/tests/Unit/Services/AiStatusNotificationServiceTest.php new file mode 100644 index 00000000..3610386a --- /dev/null +++ b/tests/Unit/Services/AiStatusNotificationServiceTest.php @@ -0,0 +1,136 @@ +create(['status' => 'published']); + $owner = User::factory()->create(); + $event->tenant->update(['user_id' => $owner->id]); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'status' => AiEditRequest::STATUS_SUCCEEDED, + 'safety_state' => 'passed', + 'requested_by_device_id' => 'device-ai-1', + 'idempotency_key' => 'notify-guest-success-1', + 'queued_at' => now()->subMinute(), + 'completed_at' => now(), + ]); + + app(AiStatusNotificationService::class)->notifyTerminalOutcome($request); + + $this->assertDatabaseHas('guest_notifications', [ + 'event_id' => $event->id, + 'type' => 'upload_alert', + 'audience_scope' => 'guest', + 'target_identifier' => 'device-ai-1', + ]); + + $this->assertDatabaseHas('tenant_notification_logs', [ + 'tenant_id' => $event->tenant_id, + 'type' => 'ai_edit_succeeded', + 'channel' => 'system', + 'status' => 'sent', + ]); + + $this->assertDatabaseHas('tenant_notification_receipts', [ + 'tenant_id' => $event->tenant_id, + 'user_id' => $owner->id, + 'status' => 'delivered', + ]); + } + + public function test_it_creates_only_tenant_notification_for_tenant_admin_requests(): void + { + $event = Event::factory()->create(['status' => 'published']); + $owner = User::factory()->create(); + $event->tenant->update(['user_id' => $owner->id]); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'requested_by_user_id' => $owner->id, + 'status' => AiEditRequest::STATUS_FAILED, + 'safety_state' => 'pending', + 'failure_code' => 'provider_timeout', + 'idempotency_key' => 'notify-tenant-failed-1', + 'queued_at' => now()->subMinute(), + 'completed_at' => now(), + ]); + + app(AiStatusNotificationService::class)->notifyTerminalOutcome($request); + + $this->assertDatabaseCount('guest_notifications', 0); + $this->assertDatabaseHas('tenant_notification_logs', [ + 'tenant_id' => $event->tenant_id, + 'type' => 'ai_edit_failed', + 'channel' => 'system', + 'status' => 'sent', + ]); + } + + public function test_it_deduplicates_terminal_notifications_per_request_and_status(): void + { + $event = Event::factory()->create(['status' => 'published']); + $owner = User::factory()->create(); + $event->tenant->update(['user_id' => $owner->id]); + + $photo = Photo::factory()->for($event)->create([ + 'tenant_id' => $event->tenant_id, + 'status' => 'approved', + ]); + + $request = AiEditRequest::query()->create([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'photo_id' => $photo->id, + 'status' => AiEditRequest::STATUS_BLOCKED, + 'safety_state' => 'blocked', + 'requested_by_device_id' => 'device-ai-dup', + 'idempotency_key' => 'notify-dedupe-1', + 'queued_at' => now()->subMinute(), + 'completed_at' => now(), + ]); + + $service = app(AiStatusNotificationService::class); + $service->notifyTerminalOutcome($request); + $service->notifyTerminalOutcome($request->fresh()); + + $this->assertDatabaseCount('guest_notifications', 1); + $this->assertDatabaseCount('tenant_notification_logs', 1); + } +}