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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
\App\Console\Commands\MonitorStorageCommand::class,
|
||||
\App\Console\Commands\DispatchStorageArchiveCommand::class,
|
||||
\App\Console\Commands\CheckUploadQueuesCommand::class,
|
||||
\App\Console\Commands\AiEditsRecoverStuckCommand::class,
|
||||
\App\Console\Commands\AiEditsPruneCommand::class,
|
||||
\App\Console\Commands\PurgeExpiredDataExports::class,
|
||||
\App\Console\Commands\ProcessTenantRetention::class,
|
||||
\App\Console\Commands\SendGuestFeedbackReminders::class,
|
||||
@@ -65,6 +67,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
->dailyAt('01:00')
|
||||
->withoutOverlapping()
|
||||
->onFailure($onFailure('storage:archive-pending'));
|
||||
$schedule->command('ai-edits:prune')
|
||||
->dailyAt('02:30')
|
||||
->withoutOverlapping()
|
||||
->onFailure($onFailure('ai-edits:prune'));
|
||||
$schedule->command('photobooth:cleanup-expired')
|
||||
->hourly()
|
||||
->withoutOverlapping()
|
||||
|
||||
@@ -24,10 +24,14 @@ return [
|
||||
'abuse' => [
|
||||
'guest_submit_per_minute' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_MINUTE', 8),
|
||||
'guest_submit_per_hour' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_HOUR', 40),
|
||||
'guest_submit_per_event_per_minute' => (int) env('AI_EDITING_GUEST_SUBMIT_PER_EVENT_PER_MINUTE', 40),
|
||||
'guest_status_per_minute' => (int) env('AI_EDITING_GUEST_STATUS_PER_MINUTE', 60),
|
||||
'tenant_submit_per_minute' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_MINUTE', 30),
|
||||
'tenant_submit_per_hour' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_HOUR', 240),
|
||||
'tenant_submit_per_event_per_minute' => (int) env('AI_EDITING_TENANT_SUBMIT_PER_EVENT_PER_MINUTE', 120),
|
||||
'tenant_status_per_minute' => (int) env('AI_EDITING_TENANT_STATUS_PER_MINUTE', 120),
|
||||
'escalation_threshold_per_hour' => (int) env('AI_EDITING_ESCALATION_THRESHOLD_PER_HOUR', 25),
|
||||
'escalation_cooldown_minutes' => (int) env('AI_EDITING_ESCALATION_COOLDOWN_MINUTES', 30),
|
||||
],
|
||||
|
||||
'queue' => [
|
||||
@@ -38,6 +42,17 @@ return [
|
||||
|
||||
'billing' => [
|
||||
'default_unit_cost_usd' => (float) env('AI_EDITING_DEFAULT_UNIT_COST_USD', 0.01),
|
||||
'budget' => [
|
||||
'soft_cap_usd' => env('AI_EDITING_BUDGET_SOFT_CAP_USD'),
|
||||
'hard_cap_usd' => env('AI_EDITING_BUDGET_HARD_CAP_USD'),
|
||||
'hard_stop_enabled' => (bool) env('AI_EDITING_BUDGET_HARD_STOP_ENABLED', true),
|
||||
'alert_cooldown_minutes' => (int) env('AI_EDITING_BUDGET_ALERT_COOLDOWN_MINUTES', 30),
|
||||
],
|
||||
],
|
||||
|
||||
'retention' => [
|
||||
'request_days' => (int) env('AI_EDITING_REQUEST_RETENTION_DAYS', 90),
|
||||
'usage_ledger_days' => (int) env('AI_EDITING_USAGE_LEDGER_RETENTION_DAYS', 365),
|
||||
],
|
||||
|
||||
'providers' => [
|
||||
@@ -45,4 +60,10 @@ return [
|
||||
'mode' => env('AI_EDITING_RUNWARE_MODE', 'live'),
|
||||
],
|
||||
],
|
||||
|
||||
'observability' => [
|
||||
'failure_rate_alert_threshold' => (float) env('AI_EDITING_FAILURE_RATE_ALERT_THRESHOLD', 0.35),
|
||||
'failure_rate_min_samples' => (int) env('AI_EDITING_FAILURE_RATE_MIN_SAMPLES', 10),
|
||||
'latency_warning_ms' => (int) env('AI_EDITING_LATENCY_WARNING_MS', 15000),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ai_styles', function (Blueprint $table) {
|
||||
$table->unsignedInteger('version')->default(1)->after('name');
|
||||
$table->index(['is_active', 'version']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ai_styles', function (Blueprint $table) {
|
||||
$table->dropIndex(['is_active', 'version']);
|
||||
$table->dropColumn('version');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -337,6 +337,27 @@
|
||||
"title": "Gäste-Nutzung Warnung",
|
||||
"body": "{{event}} liegt bei {{used}} / {{limit}} Gästen."
|
||||
},
|
||||
"aiEditSucceeded": {
|
||||
"title": "AI-Edit abgeschlossen",
|
||||
"body": "{{event}} hat ein fertiges AI-Edit."
|
||||
},
|
||||
"aiEditFailed": {
|
||||
"title": "AI-Edit fehlgeschlagen",
|
||||
"body": "{{event}} konnte ein AI-Edit nicht abschließen. Grund: {{reason}}.",
|
||||
"reasonUnknown": "unbekannt"
|
||||
},
|
||||
"aiEditBlocked": {
|
||||
"title": "AI-Edit blockiert",
|
||||
"body": "Ein AI-Edit für {{event}} wurde durch Sicherheitsprüfungen blockiert."
|
||||
},
|
||||
"aiBudgetSoftCap": {
|
||||
"title": "AI-Budget Warnung",
|
||||
"body": "{{event}} hat {{spend}} USD von {{cap}} USD AI-Budget erreicht."
|
||||
},
|
||||
"aiBudgetHardCap": {
|
||||
"title": "AI-Budget ausgeschöpft",
|
||||
"body": "{{event}} hat das harte AI-Budget-Limit von {{cap}} USD erreicht."
|
||||
},
|
||||
"generic": {
|
||||
"body": "Benachrichtigung über {{channel}}."
|
||||
},
|
||||
@@ -354,6 +375,7 @@
|
||||
"gallery": "Galerie",
|
||||
"events": "Events",
|
||||
"package": "Paket",
|
||||
"ai": "AI",
|
||||
"general": "Allgemein"
|
||||
},
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
|
||||
@@ -333,6 +333,27 @@
|
||||
"title": "Guest usage warning",
|
||||
"body": "{{event}} is at {{used}} / {{limit}} guests."
|
||||
},
|
||||
"aiEditSucceeded": {
|
||||
"title": "AI edit completed",
|
||||
"body": "{{event}} has a completed AI edit ready."
|
||||
},
|
||||
"aiEditFailed": {
|
||||
"title": "AI edit failed",
|
||||
"body": "{{event}} could not finish an AI edit. Reason: {{reason}}.",
|
||||
"reasonUnknown": "unknown"
|
||||
},
|
||||
"aiEditBlocked": {
|
||||
"title": "AI edit blocked",
|
||||
"body": "{{event}} AI edit was blocked by safety checks."
|
||||
},
|
||||
"aiBudgetSoftCap": {
|
||||
"title": "AI budget warning",
|
||||
"body": "{{event}} reached {{spend}} USD of {{cap}} USD AI budget."
|
||||
},
|
||||
"aiBudgetHardCap": {
|
||||
"title": "AI budget exhausted",
|
||||
"body": "{{event}} reached the hard AI budget cap of {{cap}} USD."
|
||||
},
|
||||
"generic": {
|
||||
"body": "Notification sent via {{channel}}."
|
||||
},
|
||||
@@ -350,6 +371,7 @@
|
||||
"gallery": "Gallery",
|
||||
"events": "Events",
|
||||
"package": "Package",
|
||||
"ai": "AI",
|
||||
"general": "General"
|
||||
},
|
||||
"markAllRead": "Mark all read",
|
||||
|
||||
@@ -144,6 +144,10 @@ function formatLog(
|
||||
const used = typeof ctx.used === 'number' ? ctx.used : null;
|
||||
const remaining = typeof ctx.remaining === 'number' ? ctx.remaining : null;
|
||||
const days = typeof ctx.day === 'number' ? ctx.day : null;
|
||||
const spendUsd = typeof ctx.spend_usd === 'number' ? ctx.spend_usd : null;
|
||||
const softCapUsd = typeof ctx.soft_cap_usd === 'number' ? ctx.soft_cap_usd : null;
|
||||
const hardCapUsd = typeof ctx.hard_cap_usd === 'number' ? ctx.hard_cap_usd : null;
|
||||
const failureCode = typeof ctx.failure_code === 'string' ? ctx.failure_code : null;
|
||||
const ctxEventId = ctx.event_id ?? ctx.eventId;
|
||||
const eventId = typeof ctxEventId === 'string' ? Number(ctxEventId) : (typeof ctxEventId === 'number' ? ctxEventId : null);
|
||||
const name = eventName ?? t('mobileNotifications.unknownEvent', 'Event');
|
||||
@@ -165,6 +169,12 @@ function formatLog(
|
||||
case 'package_expiring':
|
||||
case 'package_expired':
|
||||
return 'package';
|
||||
case 'ai_edit_succeeded':
|
||||
case 'ai_edit_failed':
|
||||
case 'ai_edit_blocked':
|
||||
case 'ai_budget_soft_cap':
|
||||
case 'ai_budget_hard_cap':
|
||||
return 'ai';
|
||||
default:
|
||||
return 'general';
|
||||
}
|
||||
@@ -276,6 +286,80 @@ function formatLog(
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
case 'ai_edit_succeeded':
|
||||
return {
|
||||
id: String(log.id),
|
||||
title: t('notificationLogs.aiEditSucceeded.title', 'AI edit completed'),
|
||||
body: t('notificationLogs.aiEditSucceeded.body', '{{event}} has a completed AI edit ready.', {
|
||||
event: name,
|
||||
}),
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'info',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
case 'ai_edit_failed':
|
||||
return {
|
||||
id: String(log.id),
|
||||
title: t('notificationLogs.aiEditFailed.title', 'AI edit failed'),
|
||||
body: t('notificationLogs.aiEditFailed.body', '{{event}} could not finish an AI edit. Reason: {{reason}}.', {
|
||||
event: name,
|
||||
reason: failureCode ?? t('notificationLogs.aiEditFailed.reasonUnknown', 'unknown'),
|
||||
}),
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
case 'ai_edit_blocked':
|
||||
return {
|
||||
id: String(log.id),
|
||||
title: t('notificationLogs.aiEditBlocked.title', 'AI edit blocked'),
|
||||
body: t('notificationLogs.aiEditBlocked.body', '{{event}} AI edit was blocked by safety checks.', {
|
||||
event: name,
|
||||
}),
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
case 'ai_budget_soft_cap':
|
||||
return {
|
||||
id: String(log.id),
|
||||
title: t('notificationLogs.aiBudgetSoftCap.title', 'AI budget warning'),
|
||||
body: t('notificationLogs.aiBudgetSoftCap.body', '{{event}} reached {{spend}} USD of {{cap}} USD AI budget.', {
|
||||
event: name,
|
||||
spend: spendUsd ?? '—',
|
||||
cap: softCapUsd ?? '—',
|
||||
}),
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'info',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
case 'ai_budget_hard_cap':
|
||||
return {
|
||||
id: String(log.id),
|
||||
title: t('notificationLogs.aiBudgetHardCap.title', 'AI budget exhausted'),
|
||||
body: t('notificationLogs.aiBudgetHardCap.body', '{{event}} reached the hard AI budget cap of {{cap}} USD.', {
|
||||
event: name,
|
||||
cap: hardCapUsd ?? '—',
|
||||
}),
|
||||
time: log.sent_at ?? log.failed_at ?? '',
|
||||
tone: 'warning',
|
||||
eventId,
|
||||
eventName,
|
||||
is_read: isRead,
|
||||
scope,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
id: String(log.id),
|
||||
@@ -379,6 +463,16 @@ export default function MobileNotificationsPage() {
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
void reload();
|
||||
}, 90_000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -588,6 +682,7 @@ export default function MobileNotificationsPage() {
|
||||
{ key: 'gallery', label: t('notificationLogs.scope.gallery', 'Gallery') },
|
||||
{ key: 'events', label: t('notificationLogs.scope.events', 'Events') },
|
||||
{ key: 'package', label: t('notificationLogs.scope.package', 'Package') },
|
||||
{ key: 'ai', label: t('notificationLogs.scope.ai', 'AI') },
|
||||
{ key: 'general', label: t('notificationLogs.scope.general', 'General') },
|
||||
] as Array<{ key: NotificationScope | 'all'; label: string }>).map((filter) => {
|
||||
const isActive = (scopeParam ?? 'all') === filter.key;
|
||||
|
||||
@@ -10,6 +10,8 @@ export function useNotificationsBadge() {
|
||||
const { data: count = 0 } = useQuery<number>({
|
||||
queryKey: ['mobile', 'notifications', 'badge', 'tenant'],
|
||||
staleTime: 60_000,
|
||||
refetchInterval: 90_000,
|
||||
refetchIntervalInBackground: true,
|
||||
queryFn: async () => {
|
||||
const logs = await listNotificationLogs({ perPage: 1 });
|
||||
const meta: any = logs.meta ?? {};
|
||||
|
||||
@@ -30,4 +30,14 @@ describe('groupNotificationsByScope', () => {
|
||||
const grouped = groupNotificationsByScope(items);
|
||||
expect(grouped.map((group) => group.scope)).toEqual(['photos', 'events', 'general']);
|
||||
});
|
||||
|
||||
it('places ai scope before general', () => {
|
||||
const items: Item[] = [
|
||||
{ id: '1', scope: 'general', is_read: true },
|
||||
{ id: '2', scope: 'ai', is_read: true },
|
||||
];
|
||||
|
||||
const grouped = groupNotificationsByScope(items);
|
||||
expect(grouped.map((group) => group.scope)).toEqual(['ai', 'general']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'general';
|
||||
export type NotificationScope = 'photos' | 'guests' | 'gallery' | 'events' | 'package' | 'ai' | 'general';
|
||||
|
||||
export type ScopedNotification = {
|
||||
scope: NotificationScope;
|
||||
@@ -17,6 +17,7 @@ const SCOPE_ORDER: NotificationScope[] = [
|
||||
'gallery',
|
||||
'events',
|
||||
'package',
|
||||
'ai',
|
||||
'general',
|
||||
];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class AiEditingDataModelTest extends TestCase
|
||||
'key',
|
||||
'provider',
|
||||
'provider_model',
|
||||
'version',
|
||||
'requires_source_image',
|
||||
'is_premium',
|
||||
] as $column) {
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Event;
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
@@ -302,6 +303,7 @@ class EventAiEditControllerTest extends TestCase
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.0.id', $allowed->id)
|
||||
->assertJsonPath('data.0.version', 1)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('meta.required_feature', 'ai_styling')
|
||||
->assertJsonPath('meta.allow_custom_prompt', false)
|
||||
@@ -594,6 +596,129 @@ class EventAiEditControllerTest extends TestCase
|
||||
$second->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_guest_submit_endpoint_enforces_event_scope_rate_limit_across_devices(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.abuse.guest_submit_per_minute' => 10,
|
||||
'ai-editing.abuse.guest_submit_per_hour' => 100,
|
||||
'ai-editing.abuse.guest_submit_per_event_per_minute' => 1,
|
||||
]);
|
||||
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-event-rate-limit'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$first = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-a'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'First request.',
|
||||
'idempotency_key' => 'guest-event-rate-limit-1',
|
||||
]);
|
||||
$first->assertCreated();
|
||||
|
||||
$second = $this->withHeaders(['X-Device-Id' => 'guest-device-event-limit-b'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Second request.',
|
||||
'idempotency_key' => 'guest-event-rate-limit-2',
|
||||
]);
|
||||
$second->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$tenantSettings = (array) ($event->tenant->settings ?? []);
|
||||
data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01);
|
||||
$event->tenant->update(['settings' => $tenantSettings]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
AiUsageLedger::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 0.02,
|
||||
'amount_usd' => 0.02,
|
||||
'currency' => 'USD',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-budget-hard-cap'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-budget-cap'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Stylize image.',
|
||||
'idempotency_key' => 'guest-budget-cap-1',
|
||||
]);
|
||||
|
||||
$response->assertForbidden()
|
||||
->assertJsonPath('error.code', 'budget_hard_cap_reached')
|
||||
->assertJsonPath('error.meta.budget.hard_reached', true);
|
||||
}
|
||||
|
||||
public function test_guest_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.abuse.escalation_threshold_per_hour' => 1,
|
||||
'ai-editing.abuse.escalation_cooldown_minutes' => 1,
|
||||
]);
|
||||
AiEditingSetting::flushCache();
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
['blocked_terms' => ['explicit']]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create([
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$token = app(EventJoinTokenService::class)
|
||||
->createToken($event, ['label' => 'guest-ai-escalation'])
|
||||
->getAttribute('plain_token');
|
||||
|
||||
$response = $this->withHeaders(['X-Device-Id' => 'guest-device-escalation'])
|
||||
->postJson("/api/v1/events/{$token}/photos/{$photo->id}/ai-edits", [
|
||||
'prompt' => 'Create an explicit style image.',
|
||||
'idempotency_key' => 'guest-escalation-1',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
|
||||
->assertJsonFragment(['abuse_escalation_threshold_reached']);
|
||||
|
||||
$requestId = (int) $response->json('data.id');
|
||||
$editRequest = AiEditRequest::query()->find($requestId);
|
||||
|
||||
$this->assertNotNull($editRequest);
|
||||
$this->assertIsArray($editRequest?->metadata);
|
||||
$this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null);
|
||||
$this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false));
|
||||
}
|
||||
|
||||
public function test_guest_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\Tenant;
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
@@ -305,6 +306,7 @@ class TenantAiEditControllerTest extends TenantTestCase
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.0.id', $active->id)
|
||||
->assertJsonPath('data.0.version', 1)
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('meta.required_feature', 'ai_styling')
|
||||
->assertJsonPath('meta.event_enabled', false)
|
||||
@@ -540,6 +542,148 @@ class TenantAiEditControllerTest extends TenantTestCase
|
||||
$second->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_tenant_submit_endpoint_enforces_event_scope_rate_limit(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.abuse.tenant_submit_per_minute' => 10,
|
||||
'ai-editing.abuse.tenant_submit_per_hour' => 100,
|
||||
'ai-editing.abuse.tenant_submit_per_event_per_minute' => 1,
|
||||
]);
|
||||
|
||||
$event = Event::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'tenant-event-rate-limit-style',
|
||||
'name' => 'Tenant Event Rate Limit',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$first = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'prompt' => 'First request.',
|
||||
'idempotency_key' => 'tenant-event-rate-limit-1',
|
||||
]);
|
||||
$first->assertCreated();
|
||||
|
||||
$second = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'prompt' => 'Second request.',
|
||||
'idempotency_key' => 'tenant-event-rate-limit-2',
|
||||
]);
|
||||
$second->assertStatus(429);
|
||||
}
|
||||
|
||||
public function test_tenant_cannot_create_ai_edit_when_hard_budget_cap_is_reached(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$tenantSettings = (array) ($this->tenant->settings ?? []);
|
||||
data_set($tenantSettings, 'ai_editing.budget.hard_cap_usd', 0.01);
|
||||
$this->tenant->update(['settings' => $tenantSettings]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'tenant-budget-cap-style',
|
||||
'name' => 'Tenant Budget Cap',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
AiUsageLedger::query()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 0.02,
|
||||
'amount_usd' => 0.02,
|
||||
'currency' => 'USD',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'prompt' => 'Apply AI style.',
|
||||
'idempotency_key' => 'tenant-budget-cap-1',
|
||||
]);
|
||||
|
||||
$response->assertForbidden()
|
||||
->assertJsonPath('error.code', 'budget_hard_cap_reached')
|
||||
->assertJsonPath('error.meta.budget.hard_reached', true);
|
||||
}
|
||||
|
||||
public function test_tenant_prompt_block_records_abuse_metadata_when_escalation_threshold_is_reached(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.abuse.escalation_threshold_per_hour' => 1,
|
||||
'ai-editing.abuse.escalation_cooldown_minutes' => 1,
|
||||
]);
|
||||
AiEditingSetting::flushCache();
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
['blocked_terms' => ['weapon']]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'published',
|
||||
]);
|
||||
$this->attachEntitledEventPackage($event);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'tenant-escalation-style',
|
||||
'name' => 'Tenant Escalation',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/ai-edits", [
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'prompt' => 'Add weapon effects.',
|
||||
'idempotency_key' => 'tenant-escalation-1',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.status', AiEditRequest::STATUS_BLOCKED)
|
||||
->assertJsonFragment(['abuse_escalation_threshold_reached']);
|
||||
|
||||
$requestId = (int) $response->json('data.id');
|
||||
$editRequest = AiEditRequest::query()->find($requestId);
|
||||
|
||||
$this->assertNotNull($editRequest);
|
||||
$this->assertIsArray($editRequest?->metadata);
|
||||
$this->assertSame('prompt_block', $editRequest?->metadata['abuse']['type'] ?? null);
|
||||
$this->assertTrue((bool) ($editRequest?->metadata['abuse']['escalated'] ?? false));
|
||||
}
|
||||
|
||||
public function test_tenant_cannot_create_ai_edit_when_event_ai_feature_is_disabled(): void
|
||||
{
|
||||
$event = Event::factory()->create([
|
||||
@@ -685,7 +829,10 @@ class TenantAiEditControllerTest extends TenantTestCase
|
||||
->assertJsonPath('data.total', 2)
|
||||
->assertJsonPath('data.status_counts.succeeded', 1)
|
||||
->assertJsonPath('data.status_counts.failed', 1)
|
||||
->assertJsonPath('data.failed_total', 1);
|
||||
->assertJsonPath('data.failed_total', 1)
|
||||
->assertJsonPath('data.usage.debit_count', 0)
|
||||
->assertJsonPath('data.observability.provider_runs_total', 0)
|
||||
->assertJsonPath('data.budget.hard_stop_enabled', true);
|
||||
}
|
||||
|
||||
private function attachEntitledEventPackage(Event $event): EventPackage
|
||||
|
||||
172
tests/Feature/Console/AiEditsPruneCommandTest.php
Normal file
172
tests/Feature/Console/AiEditsPruneCommandTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Models\AiEditOutput;
|
||||
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiEditsPruneCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_command_prunes_stale_requests_and_ledgers(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.retention.request_days' => 90,
|
||||
'ai-editing.retention.usage_ledger_days' => 365,
|
||||
]);
|
||||
|
||||
[$oldRequest, $recentRequest] = $this->createRequestsForPruning();
|
||||
[$oldLedger, $recentLedger] = $this->createLedgerEntriesForPruning($oldRequest, $recentRequest);
|
||||
|
||||
$this->artisan('ai-edits:prune')
|
||||
->expectsOutputToContain('AI prune candidates')
|
||||
->expectsOutputToContain('Pruned AI data')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseMissing('ai_edit_requests', ['id' => $oldRequest->id]);
|
||||
$this->assertDatabaseMissing('ai_provider_runs', ['request_id' => $oldRequest->id]);
|
||||
$this->assertDatabaseMissing('ai_edit_outputs', ['request_id' => $oldRequest->id]);
|
||||
$this->assertDatabaseHas('ai_edit_requests', ['id' => $recentRequest->id]);
|
||||
|
||||
$this->assertDatabaseMissing('ai_usage_ledgers', ['id' => $oldLedger->id]);
|
||||
$this->assertNotNull($recentLedger);
|
||||
if ($recentLedger) {
|
||||
$this->assertDatabaseHas('ai_usage_ledgers', ['id' => $recentLedger->id]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_command_pretend_mode_does_not_delete_records(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.retention.request_days' => 90,
|
||||
'ai-editing.retention.usage_ledger_days' => 365,
|
||||
]);
|
||||
|
||||
[$oldRequest] = $this->createRequestsForPruning();
|
||||
[$oldLedger] = $this->createLedgerEntriesForPruning($oldRequest, null);
|
||||
|
||||
$this->artisan('ai-edits:prune', ['--pretend' => true])
|
||||
->expectsOutputToContain('AI prune candidates')
|
||||
->expectsOutput('Pretend mode enabled. No records were deleted.')
|
||||
->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseHas('ai_edit_requests', ['id' => $oldRequest->id]);
|
||||
$this->assertDatabaseHas('ai_usage_ledgers', ['id' => $oldLedger->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: AiEditRequest, 1: AiEditRequest}
|
||||
*/
|
||||
private function createRequestsForPruning(): array
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'prune-style',
|
||||
'name' => 'Prune Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$oldRequest = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'prompt' => 'Old request',
|
||||
'idempotency_key' => 'old-prune-request',
|
||||
'queued_at' => now()->subDays(121),
|
||||
'started_at' => now()->subDays(120),
|
||||
'completed_at' => now()->subDays(120),
|
||||
'expires_at' => now()->subDays(30),
|
||||
]);
|
||||
|
||||
AiProviderRun::query()->create([
|
||||
'request_id' => $oldRequest->id,
|
||||
'provider' => 'runware',
|
||||
'attempt' => 1,
|
||||
'provider_task_id' => 'old-task-id',
|
||||
'status' => AiProviderRun::STATUS_SUCCEEDED,
|
||||
'started_at' => now()->subDays(120),
|
||||
'finished_at' => now()->subDays(120),
|
||||
]);
|
||||
AiEditOutput::query()->create([
|
||||
'request_id' => $oldRequest->id,
|
||||
'provider_asset_id' => 'old-asset-id',
|
||||
'provider_url' => 'https://cdn.example.invalid/old.jpg',
|
||||
'safety_state' => 'passed',
|
||||
'generated_at' => now()->subDays(120),
|
||||
]);
|
||||
|
||||
$recentRequest = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'prompt' => 'Recent request',
|
||||
'idempotency_key' => 'recent-prune-request',
|
||||
'queued_at' => now()->subDays(11),
|
||||
'started_at' => now()->subDays(10),
|
||||
'completed_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
return [$oldRequest, $recentRequest];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: AiUsageLedger, 1: ?AiUsageLedger}
|
||||
*/
|
||||
private function createLedgerEntriesForPruning(AiEditRequest $oldRequest, ?AiEditRequest $recentRequest): array
|
||||
{
|
||||
$oldLedger = AiUsageLedger::query()->create([
|
||||
'tenant_id' => $oldRequest->tenant_id,
|
||||
'event_id' => $oldRequest->event_id,
|
||||
'request_id' => $oldRequest->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 0.01,
|
||||
'amount_usd' => 0.01,
|
||||
'currency' => 'USD',
|
||||
'recorded_at' => now()->subDays(400),
|
||||
]);
|
||||
|
||||
if (! $recentRequest) {
|
||||
return [$oldLedger, null];
|
||||
}
|
||||
|
||||
$recentLedger = AiUsageLedger::query()->create([
|
||||
'tenant_id' => $recentRequest->tenant_id,
|
||||
'event_id' => $recentRequest->event_id,
|
||||
'request_id' => $recentRequest->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 0.01,
|
||||
'amount_usd' => 0.01,
|
||||
'currency' => 'USD',
|
||||
'recorded_at' => now()->subDays(30),
|
||||
]);
|
||||
|
||||
return [$oldLedger, $recentLedger];
|
||||
}
|
||||
}
|
||||
125
tests/Feature/Console/AiEditsRecoverStuckCommandTest.php
Normal file
125
tests/Feature/Console/AiEditsRecoverStuckCommandTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Jobs\PollAiEditRequest;
|
||||
use App\Jobs\ProcessAiEditRequest;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiEditsRecoverStuckCommandTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_command_requeues_stuck_requests_with_scope_aware_job_selection(): void
|
||||
{
|
||||
[$queuedRequest, $processingRequest] = $this->createStuckRequests();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('ai-edits:recover-stuck', [
|
||||
'--minutes' => 10,
|
||||
'--requeue' => true,
|
||||
])->assertExitCode(0);
|
||||
|
||||
Queue::assertPushed(ProcessAiEditRequest::class, 1);
|
||||
Queue::assertPushed(PollAiEditRequest::class, 1);
|
||||
$this->assertDatabaseHas('ai_edit_requests', [
|
||||
'id' => $queuedRequest->id,
|
||||
'status' => AiEditRequest::STATUS_QUEUED,
|
||||
]);
|
||||
$this->assertDatabaseHas('ai_edit_requests', [
|
||||
'id' => $processingRequest->id,
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_command_can_mark_stuck_requests_as_failed(): void
|
||||
{
|
||||
[$queuedRequest, $processingRequest] = $this->createStuckRequests();
|
||||
|
||||
$this->artisan('ai-edits:recover-stuck', [
|
||||
'--minutes' => 10,
|
||||
'--fail' => true,
|
||||
])->assertExitCode(0);
|
||||
|
||||
$this->assertDatabaseHas('ai_edit_requests', [
|
||||
'id' => $queuedRequest->id,
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'operator_recovery_marked_failed',
|
||||
]);
|
||||
$this->assertDatabaseHas('ai_edit_requests', [
|
||||
'id' => $processingRequest->id,
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'failure_code' => 'operator_recovery_marked_failed',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: AiEditRequest, 1: AiEditRequest}
|
||||
*/
|
||||
private function createStuckRequests(): array
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'recovery-style',
|
||||
'name' => 'Recovery Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$queued = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_QUEUED,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'stuck-queued-1',
|
||||
'queued_at' => now()->subMinutes(45),
|
||||
'updated_at' => now()->subMinutes(45),
|
||||
]);
|
||||
|
||||
$processing = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'stuck-processing-1',
|
||||
'queued_at' => now()->subMinutes(50),
|
||||
'started_at' => now()->subMinutes(40),
|
||||
'updated_at' => now()->subMinutes(40),
|
||||
]);
|
||||
|
||||
AiProviderRun::query()->create([
|
||||
'request_id' => $processing->id,
|
||||
'provider' => 'runware',
|
||||
'attempt' => 1,
|
||||
'provider_task_id' => 'runware-task-recovery-1',
|
||||
'status' => AiProviderRun::STATUS_RUNNING,
|
||||
'started_at' => now()->subMinutes(39),
|
||||
]);
|
||||
|
||||
return [$queued, $processing];
|
||||
}
|
||||
}
|
||||
142
tests/Feature/Jobs/PollAiEditRequestTest.php
Normal file
142
tests/Feature/Jobs/PollAiEditRequestTest.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Jobs;
|
||||
|
||||
use App\Jobs\PollAiEditRequest;
|
||||
use App\Models\AiEditingSetting;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiProviderRun;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PollAiEditRequestTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
AiEditingSetting::flushCache();
|
||||
}
|
||||
|
||||
public function test_it_marks_request_failed_when_poll_attempts_are_exhausted(): void
|
||||
{
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
[
|
||||
'runware_mode' => 'live',
|
||||
'queue_auto_dispatch' => false,
|
||||
'queue_max_polls' => 1,
|
||||
]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'poll-exhaust-style',
|
||||
'name' => 'Poll Exhaust',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'poll-exhaust-1',
|
||||
'queued_at' => now()->subMinutes(3),
|
||||
'started_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
$providerRun = AiProviderRun::query()->create([
|
||||
'request_id' => $request->id,
|
||||
'provider' => 'runware',
|
||||
'attempt' => 1,
|
||||
'provider_task_id' => 'runware-task-1',
|
||||
'status' => AiProviderRun::STATUS_RUNNING,
|
||||
'started_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock(RunwareAiImageProvider::class);
|
||||
$provider->shouldReceive('poll')
|
||||
->once()
|
||||
->withArgs(function (AiEditRequest $polledRequest, string $taskId): bool {
|
||||
return $polledRequest->id > 0 && $taskId === 'runware-task-1';
|
||||
})
|
||||
->andReturn(AiProviderResult::processing('runware-task-1'));
|
||||
$this->app->instance(RunwareAiImageProvider::class, $provider);
|
||||
|
||||
PollAiEditRequest::dispatchSync($request->id, 'runware-task-1', 1);
|
||||
|
||||
$request->refresh();
|
||||
$providerRun->refresh();
|
||||
$latestRun = $request->providerRuns()->latest('attempt')->first();
|
||||
|
||||
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
|
||||
$this->assertSame('provider_poll_timeout', $request->failure_code);
|
||||
$this->assertNotNull($request->completed_at);
|
||||
$this->assertNotNull($latestRun);
|
||||
$this->assertSame(AiProviderRun::STATUS_FAILED, $latestRun?->status);
|
||||
$this->assertSame('Polling exhausted after 1 attempt(s).', $latestRun?->error_message);
|
||||
}
|
||||
|
||||
public function test_poll_job_failed_hook_marks_request_as_failed(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'poll-failed-hook-style',
|
||||
'name' => 'Poll Failed Hook',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'poll-failed-hook-1',
|
||||
'queued_at' => now()->subMinute(),
|
||||
'started_at' => now()->subSeconds(30),
|
||||
]);
|
||||
|
||||
$job = new PollAiEditRequest($request->id, 'runware-task-2', 2);
|
||||
$job->failed(new RuntimeException('Polling crashed'));
|
||||
|
||||
$request->refresh();
|
||||
|
||||
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
|
||||
$this->assertSame('queue_job_failed', $request->failure_code);
|
||||
$this->assertSame('Polling crashed', $request->failure_message);
|
||||
$this->assertNotNull($request->completed_at);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ use App\Models\AiStyle;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProcessAiEditRequestTest extends TestCase
|
||||
@@ -187,5 +190,106 @@ class ProcessAiEditRequestTest extends TestCase
|
||||
$this->assertNotNull($request->completed_at);
|
||||
$this->assertSame(0, $request->outputs()->count());
|
||||
$this->assertSame(1, $request->providerRuns()->count());
|
||||
$this->assertIsArray($request->metadata);
|
||||
$this->assertSame('output_block', $request->metadata['abuse']['type'] ?? null);
|
||||
}
|
||||
|
||||
public function test_it_marks_request_failed_when_provider_returns_processing_without_task_id(): void
|
||||
{
|
||||
AiEditingSetting::query()->create(array_merge(
|
||||
AiEditingSetting::defaults(),
|
||||
[
|
||||
'runware_mode' => 'live',
|
||||
'queue_auto_dispatch' => false,
|
||||
]
|
||||
));
|
||||
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'processing-no-task',
|
||||
'name' => 'Processing Without Task ID',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_QUEUED,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'job-processing-no-task',
|
||||
'queued_at' => now(),
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock(RunwareAiImageProvider::class);
|
||||
$provider->shouldReceive('submit')
|
||||
->once()
|
||||
->andReturn(new AiProviderResult(status: 'processing', providerTaskId: ''));
|
||||
$this->app->instance(RunwareAiImageProvider::class, $provider);
|
||||
|
||||
ProcessAiEditRequest::dispatchSync($request->id);
|
||||
|
||||
$request->refresh();
|
||||
$run = $request->providerRuns()->first();
|
||||
|
||||
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
|
||||
$this->assertSame('provider_task_id_missing', $request->failure_code);
|
||||
$this->assertNotNull($request->completed_at);
|
||||
$this->assertNotNull($run);
|
||||
$this->assertSame('failed', $run?->status);
|
||||
$this->assertSame('Provider returned processing state without task identifier.', $run?->error_message);
|
||||
}
|
||||
|
||||
public function test_process_job_failed_hook_marks_request_as_failed(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'failed-hook-style',
|
||||
'name' => 'Failed Hook Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'requires_source_image' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => AiEditRequest::STATUS_PROCESSING,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Transform image style.',
|
||||
'idempotency_key' => 'job-failed-hook-1',
|
||||
'queued_at' => now()->subMinute(),
|
||||
'started_at' => now()->subSeconds(30),
|
||||
]);
|
||||
|
||||
$job = new ProcessAiEditRequest($request->id);
|
||||
$job->failed(new RuntimeException('Queue worker timeout'));
|
||||
|
||||
$request->refresh();
|
||||
|
||||
$this->assertSame(AiEditRequest::STATUS_FAILED, $request->status);
|
||||
$this->assertSame('queue_job_failed', $request->failure_code);
|
||||
$this->assertSame('Queue worker timeout', $request->failure_message);
|
||||
$this->assertNotNull($request->completed_at);
|
||||
}
|
||||
}
|
||||
|
||||
46
tests/Unit/Models/AiStyleVersioningTest.php
Normal file
46
tests/Unit/Models/AiStyleVersioningTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Models\AiStyle;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiStyleVersioningTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_style_defaults_to_version_one_on_create(): void
|
||||
{
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'style-version-default',
|
||||
'name' => 'Version Default',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $style->version);
|
||||
}
|
||||
|
||||
public function test_style_version_increments_when_core_style_fields_change(): void
|
||||
{
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'style-version-increment',
|
||||
'name' => 'Version Increment',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'prompt_template' => 'Initial prompt',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $style->version);
|
||||
|
||||
$style->update([
|
||||
'prompt_template' => 'Updated prompt',
|
||||
]);
|
||||
|
||||
$style->refresh();
|
||||
$this->assertSame(2, $style->version);
|
||||
}
|
||||
}
|
||||
126
tests/Unit/Services/AiBudgetGuardServiceTest.php
Normal file
126
tests/Unit/Services/AiBudgetGuardServiceTest.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use App\Services\AiEditing\AiBudgetGuardService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiBudgetGuardServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_it_allows_requests_when_spend_is_below_caps(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
|
||||
$settings = (array) ($event->tenant->settings ?? []);
|
||||
data_set($settings, 'ai_editing.budget.soft_cap_usd', 10.0);
|
||||
data_set($settings, 'ai_editing.budget.hard_cap_usd', 20.0);
|
||||
$event->tenant->update(['settings' => $settings]);
|
||||
|
||||
AiUsageLedger::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 3.0,
|
||||
'amount_usd' => 3.0,
|
||||
'currency' => 'USD',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$decision = app(AiBudgetGuardService::class)->evaluateForEvent($event->fresh('tenant'));
|
||||
|
||||
$this->assertTrue($decision['allowed']);
|
||||
$this->assertFalse($decision['budget']['soft_reached']);
|
||||
$this->assertFalse($decision['budget']['hard_reached']);
|
||||
$this->assertSame(3.0, $decision['budget']['current_spend_usd']);
|
||||
}
|
||||
|
||||
public function test_it_blocks_requests_when_hard_cap_is_reached_without_override(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$owner = User::factory()->create();
|
||||
$event->tenant->update(['user_id' => $owner->id]);
|
||||
|
||||
$settings = (array) ($event->tenant->settings ?? []);
|
||||
data_set($settings, 'ai_editing.budget.hard_cap_usd', 5.0);
|
||||
$event->tenant->update(['settings' => $settings]);
|
||||
|
||||
AiUsageLedger::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 5.0,
|
||||
'amount_usd' => 5.0,
|
||||
'currency' => 'USD',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$decision = app(AiBudgetGuardService::class)->evaluateForEvent($event->fresh('tenant'));
|
||||
|
||||
$this->assertFalse($decision['allowed']);
|
||||
$this->assertSame('budget_hard_cap_reached', $decision['reason_code']);
|
||||
$this->assertTrue($decision['budget']['hard_reached']);
|
||||
$this->assertFalse($decision['budget']['override_active']);
|
||||
|
||||
$this->assertDatabaseHas('tenant_notification_logs', [
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'type' => 'ai_budget_hard_cap',
|
||||
'channel' => 'system',
|
||||
'status' => 'sent',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('tenant_notification_receipts', [
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'user_id' => $owner->id,
|
||||
'status' => 'delivered',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_throttles_soft_cap_notifications_with_cooldown(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
|
||||
$settings = (array) ($event->tenant->settings ?? []);
|
||||
data_set($settings, 'ai_editing.budget.soft_cap_usd', 2.0);
|
||||
data_set($settings, 'ai_editing.budget.hard_cap_usd', 100.0);
|
||||
$event->tenant->update(['settings' => $settings]);
|
||||
|
||||
AiUsageLedger::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => 3.0,
|
||||
'amount_usd' => 3.0,
|
||||
'currency' => 'USD',
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(AiBudgetGuardService::class);
|
||||
$service->evaluateForEvent($event->fresh('tenant'));
|
||||
$service->evaluateForEvent($event->fresh('tenant'));
|
||||
|
||||
$this->assertSame(
|
||||
1,
|
||||
\App\Models\TenantNotificationLog::query()
|
||||
->where('tenant_id', $event->tenant_id)
|
||||
->where('type', 'ai_budget_soft_cap')
|
||||
->count()
|
||||
);
|
||||
}
|
||||
}
|
||||
96
tests/Unit/Services/AiObservabilityServiceTest.php
Normal file
96
tests/Unit/Services/AiObservabilityServiceTest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Services\AiEditing\AiObservabilityService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiObservabilityServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_records_terminal_outcomes_in_cache(): void
|
||||
{
|
||||
$request = $this->makeRequest(AiEditRequest::STATUS_PROCESSING);
|
||||
|
||||
app(AiObservabilityService::class)->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_SUCCEEDED,
|
||||
1200,
|
||||
false,
|
||||
'process'
|
||||
);
|
||||
|
||||
$bucket = now()->format('YmdH');
|
||||
$prefix = sprintf('ai-editing:obs:tenant:%d:event:%d:hour:%s', $request->tenant_id, $request->event_id, $bucket);
|
||||
|
||||
$this->assertSame(1, (int) Cache::get($prefix.':total'));
|
||||
$this->assertSame(1, (int) Cache::get($prefix.':succeeded'));
|
||||
$this->assertSame(1200, (int) Cache::get($prefix.':duration_total_ms'));
|
||||
}
|
||||
|
||||
public function test_it_logs_failure_rate_alert_when_threshold_is_reached(): void
|
||||
{
|
||||
config([
|
||||
'ai-editing.observability.failure_rate_alert_threshold' => 0.5,
|
||||
'ai-editing.observability.failure_rate_min_samples' => 1,
|
||||
]);
|
||||
|
||||
$request = $this->makeRequest(AiEditRequest::STATUS_PROCESSING);
|
||||
Log::spy();
|
||||
|
||||
app(AiObservabilityService::class)->recordTerminalOutcome(
|
||||
$request,
|
||||
AiEditRequest::STATUS_FAILED,
|
||||
500,
|
||||
false,
|
||||
'poll'
|
||||
);
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(function (string $message, array $context): bool {
|
||||
return $message === 'AI failure-rate alert threshold reached'
|
||||
&& isset($context['failure_rate'])
|
||||
&& $context['failure_rate'] >= 0.5;
|
||||
})
|
||||
->once();
|
||||
}
|
||||
|
||||
private function makeRequest(string $status): AiEditRequest
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
$style = AiStyle::query()->create([
|
||||
'key' => 'obs-style',
|
||||
'name' => 'Observability Style',
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'style_id' => $style->id,
|
||||
'provider' => 'runware',
|
||||
'provider_model' => 'runware-default',
|
||||
'status' => $status,
|
||||
'safety_state' => 'pending',
|
||||
'prompt' => 'Observability',
|
||||
'idempotency_key' => 'obs-'.uniqid('', true),
|
||||
'queued_at' => now()->subMinute(),
|
||||
'started_at' => now()->subSeconds(30),
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
tests/Unit/Services/AiStatusNotificationServiceTest.php
Normal file
136
tests/Unit/Services/AiStatusNotificationServiceTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Models\User;
|
||||
use App\Services\AiEditing\AiStatusNotificationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AiStatusNotificationServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
public function test_it_creates_guest_and_tenant_notifications_for_guest_requests(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$owner = User::factory()->create();
|
||||
$event->tenant->update(['user_id' => $owner->id]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'status' => AiEditRequest::STATUS_SUCCEEDED,
|
||||
'safety_state' => 'passed',
|
||||
'requested_by_device_id' => 'device-ai-1',
|
||||
'idempotency_key' => 'notify-guest-success-1',
|
||||
'queued_at' => now()->subMinute(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request);
|
||||
|
||||
$this->assertDatabaseHas('guest_notifications', [
|
||||
'event_id' => $event->id,
|
||||
'type' => 'upload_alert',
|
||||
'audience_scope' => 'guest',
|
||||
'target_identifier' => 'device-ai-1',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('tenant_notification_logs', [
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'type' => 'ai_edit_succeeded',
|
||||
'channel' => 'system',
|
||||
'status' => 'sent',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('tenant_notification_receipts', [
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'user_id' => $owner->id,
|
||||
'status' => 'delivered',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_creates_only_tenant_notification_for_tenant_admin_requests(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$owner = User::factory()->create();
|
||||
$event->tenant->update(['user_id' => $owner->id]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'requested_by_user_id' => $owner->id,
|
||||
'status' => AiEditRequest::STATUS_FAILED,
|
||||
'safety_state' => 'pending',
|
||||
'failure_code' => 'provider_timeout',
|
||||
'idempotency_key' => 'notify-tenant-failed-1',
|
||||
'queued_at' => now()->subMinute(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request);
|
||||
|
||||
$this->assertDatabaseCount('guest_notifications', 0);
|
||||
$this->assertDatabaseHas('tenant_notification_logs', [
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'type' => 'ai_edit_failed',
|
||||
'channel' => 'system',
|
||||
'status' => 'sent',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_deduplicates_terminal_notifications_per_request_and_status(): void
|
||||
{
|
||||
$event = Event::factory()->create(['status' => 'published']);
|
||||
$owner = User::factory()->create();
|
||||
$event->tenant->update(['user_id' => $owner->id]);
|
||||
|
||||
$photo = Photo::factory()->for($event)->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$request = AiEditRequest::query()->create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'photo_id' => $photo->id,
|
||||
'status' => AiEditRequest::STATUS_BLOCKED,
|
||||
'safety_state' => 'blocked',
|
||||
'requested_by_device_id' => 'device-ai-dup',
|
||||
'idempotency_key' => 'notify-dedupe-1',
|
||||
'queued_at' => now()->subMinute(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(AiStatusNotificationService::class);
|
||||
$service->notifyTerminalOutcome($request);
|
||||
$service->notifyTerminalOutcome($request->fresh());
|
||||
|
||||
$this->assertDatabaseCount('guest_notifications', 1);
|
||||
$this->assertDatabaseCount('tenant_notification_logs', 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user