feat(ai): finalize AI magic edits epic rollout and operations
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user