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,

View File

@@ -7,13 +7,17 @@ use App\Http\Requests\Tenant\AiEditIndexRequest;
use App\Http\Requests\Tenant\AiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiBudgetGuardService;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStyleAccessService;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
@@ -28,9 +32,11 @@ class AiEditController extends Controller
public function __construct(
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiBudgetGuardService $budgetGuard,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
private readonly AiAbuseEscalationService $abuseEscalation,
) {}
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
@@ -123,6 +129,8 @@ class AiEditController extends Controller
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$periodStart = now()->startOfMonth();
$periodEnd = now()->endOfMonth();
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
$statusCounts = (clone $baseQuery)
->select('status', DB::raw('count(*) as aggregate'))
@@ -139,6 +147,32 @@ class AiEditController extends Controller
$lastRequestedAt = (clone $baseQuery)->max('created_at');
$total = array_sum($statusCounts);
$failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0));
$moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0);
$usageQuery = AiUsageLedger::query()
->where('tenant_id', $event->tenant_id)
->where('event_id', $event->id)
->where('recorded_at', '>=', $periodStart)
->where('recorded_at', '<=', $periodEnd);
$spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0);
$debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count());
$providerRunQuery = AiProviderRun::query()
->whereHas('request', fn ($query) => $query->where('event_id', $event->id))
->where('created_at', '>=', $periodStart)
->where('created_at', '<=', $periodEnd);
$providerRunTotal = (int) (clone $providerRunQuery)->count();
$providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count();
$averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0));
$failureRate = $total > 0 ? ($failedTotal / $total) : 0.0;
$moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0;
$providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0;
$failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
$latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
return response()->json([
'data' => [
@@ -146,8 +180,27 @@ class AiEditController extends Controller
'total' => $total,
'status_counts' => $statusCounts,
'safety_counts' => $safetyCounts,
'failed_total' => (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0)),
'failed_total' => $failedTotal,
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
'usage' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'debit_count' => $debitCount,
'spend_usd' => round($spendUsd, 5),
],
'observability' => [
'failure_rate' => round($failureRate, 5),
'moderation_hit_rate' => round($moderationHitRate, 5),
'provider_runs_total' => $providerRunTotal,
'provider_runs_failed' => $providerRunFailed,
'provider_failure_rate' => round($providerFailureRate, 5),
'avg_provider_latency_ms' => $averageProviderLatencyMs,
'alerts' => [
'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)),
'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs,
],
],
'budget' => $budgetDecision['budget'],
],
]);
}
@@ -214,6 +267,19 @@ class AiEditController extends Controller
);
}
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
if (! $budgetDecision['allowed']) {
return ApiError::response(
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
'Budget limit reached',
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
Response::HTTP_FORBIDDEN,
[
'budget' => $budgetDecision['budget'],
]
);
}
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
return ApiError::response(
'style_not_allowed',
@@ -231,6 +297,25 @@ class AiEditController extends Controller
$providerModel = $request->input('provider_model') ?: $style->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$requestedByUserId = $request->user()?->id;
$scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user';
$abuseSignal = null;
$safetyReasons = $safetyDecision->reasonCodes;
if ($safetyDecision->blocked) {
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
(int) $event->tenant_id,
(int) $event->id,
$scopeKey
);
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
}
$metadata = (array) $request->input('metadata', []);
if (is_array($abuseSignal)) {
$metadata['abuse'] = $abuseSignal;
}
$metadata['budget'] = $budgetDecision['budget'];
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
@@ -257,12 +342,12 @@ class AiEditController extends Controller
'negative_prompt' => $negativePrompt,
'input_image_path' => $photo->file_path,
'idempotency_key' => $idempotencyKey,
'safety_reasons' => $safetyDecision->reasonCodes,
'safety_reasons' => $safetyReasons,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $request->input('metadata', []),
'metadata' => $metadata,
]
);
@@ -439,6 +524,7 @@ class AiEditController extends Controller
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'version' => $style->version,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,