feat(ai): finalize AI magic edits epic rollout and operations
This commit is contained in:
69
app/Console/Commands/AiEditsPruneCommand.php
Normal file
69
app/Console/Commands/AiEditsPruneCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
179
app/Console/Commands/AiEditsRecoverStuckCommand.php
Normal file
179
app/Console/Commands/AiEditsRecoverStuckCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
221
app/Services/AiEditing/AiBudgetGuardService.php
Normal file
221
app/Services/AiEditing/AiBudgetGuardService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
90
app/Services/AiEditing/AiObservabilityService.php
Normal file
90
app/Services/AiEditing/AiObservabilityService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
164
app/Services/AiEditing/AiStatusNotificationService.php
Normal file
164
app/Services/AiEditing/AiStatusNotificationService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
64
app/Services/AiEditing/Safety/AiAbuseEscalationService.php
Normal file
64
app/Services/AiEditing/Safety/AiAbuseEscalationService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user