feat(ai): finalize AI magic edits epic rollout and operations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 22:41:51 +01:00
parent 36bed12ff9
commit 1d2242fb4d
33 changed files with 2621 additions and 18 deletions

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use App\Models\AiEditRequest;
use App\Models\AiUsageLedger;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class AiEditsPruneCommand extends Command
{
protected $signature = 'ai-edits:prune
{--request-days= : Override AI request retention days}
{--ledger-days= : Override usage ledger retention days}
{--pretend : Report counts without deleting data}';
protected $description = 'Prune stale AI edit requests and usage ledgers based on retention settings.';
public function handle(): int
{
$requestRetentionDays = max(1, (int) ($this->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;
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PollAiEditRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class AiEditsRecoverStuckCommand extends Command
{
protected $signature = 'ai-edits:recover-stuck
{--minutes=30 : Minimum age in minutes for queued/processing requests}
{--requeue : Re-dispatch stuck requests back to the queue}
{--fail : Mark stuck requests as failed}';
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
{
parent::__construct();
}
public function handle(): int
{
$minutes = max(1, (int) $this->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;
}
}

View File

@@ -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')

View File

@@ -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,

View File

@@ -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,

View File

@@ -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());
}
}

View File

@@ -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());
}
}

View File

@@ -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',
];

View File

@@ -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),
];
});

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\TenantNotificationReceipt;
use App\Services\Packages\TenantNotificationLogger;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiBudgetGuardService
{
/**
* @return array{
* allowed: bool,
* reason_code: ?string,
* message: ?string,
* budget: array{
* period_start: string,
* period_end: string,
* current_spend_usd: float,
* soft_cap_usd: ?float,
* hard_cap_usd: ?float,
* soft_reached: bool,
* hard_reached: bool,
* hard_stop_enabled: bool,
* override_active: bool
* }
* }
*/
public function evaluateForEvent(Event $event): array
{
$now = now();
$periodStart = $now->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<string, mixed> $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<string, mixed> $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;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiEditRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiObservabilityService
{
public function recordTerminalOutcome(
AiEditRequest $request,
string $status,
?int $durationMs = null,
bool $moderationBlocked = false,
string $stage = 'job'
): void {
$bucket = now()->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,
]);
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Services\AiEditing;
use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationType;
use App\Models\AiEditRequest;
use App\Models\Event;
use App\Models\Tenant;
use App\Models\TenantNotificationReceipt;
use App\Services\GuestNotificationService;
use App\Services\Packages\TenantNotificationLogger;
use Illuminate\Support\Facades\Cache;
class AiStatusNotificationService
{
public function __construct(
private readonly GuestNotificationService $guestNotifications,
private readonly TenantNotificationLogger $tenantNotificationLogger,
) {}
public function notifyTerminalOutcome(AiEditRequest $request): void
{
$status = (string) $request->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;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Services\AiEditing\Safety;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class AiAbuseEscalationService
{
public const REASON_CODE = 'abuse_escalation_threshold_reached';
/**
* @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string}
*/
public function recordPromptBlock(int $tenantId, int $eventId, string $scope): array
{
return $this->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,
];
}
}