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

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

View File

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