feat(ai): finalize AI magic edits epic rollout and operations
This commit is contained in:
221
app/Services/AiEditing/AiBudgetGuardService.php
Normal file
221
app/Services/AiEditing/AiBudgetGuardService.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantNotificationReceipt;
|
||||
use App\Services\Packages\TenantNotificationLogger;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiBudgetGuardService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* allowed: bool,
|
||||
* reason_code: ?string,
|
||||
* message: ?string,
|
||||
* budget: array{
|
||||
* period_start: string,
|
||||
* period_end: string,
|
||||
* current_spend_usd: float,
|
||||
* soft_cap_usd: ?float,
|
||||
* hard_cap_usd: ?float,
|
||||
* soft_reached: bool,
|
||||
* hard_reached: bool,
|
||||
* hard_stop_enabled: bool,
|
||||
* override_active: bool
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function evaluateForEvent(Event $event): array
|
||||
{
|
||||
$now = now();
|
||||
$periodStart = $now->copy()->startOfMonth();
|
||||
$periodEnd = $now->copy()->endOfMonth();
|
||||
|
||||
$tenantSettings = (array) ($event->tenant?->settings ?? []);
|
||||
$softCap = $this->resolveCap(
|
||||
Arr::get($tenantSettings, 'ai_editing.budget.soft_cap_usd'),
|
||||
config('ai-editing.billing.budget.soft_cap_usd')
|
||||
);
|
||||
$hardCap = $this->resolveCap(
|
||||
Arr::get($tenantSettings, 'ai_editing.budget.hard_cap_usd'),
|
||||
config('ai-editing.billing.budget.hard_cap_usd')
|
||||
);
|
||||
|
||||
if ($softCap !== null && $hardCap !== null && $softCap > $hardCap) {
|
||||
[$softCap, $hardCap] = [$hardCap, $softCap];
|
||||
}
|
||||
|
||||
$spendUsd = (float) (AiUsageLedger::query()
|
||||
->where('tenant_id', $event->tenant_id)
|
||||
->where('recorded_at', '>=', $periodStart)
|
||||
->where('recorded_at', '<=', $periodEnd)
|
||||
->where('entry_type', AiUsageLedger::TYPE_DEBIT)
|
||||
->sum('amount_usd') ?: 0.0);
|
||||
|
||||
$softReached = $softCap !== null && $spendUsd >= $softCap;
|
||||
$hardReached = $hardCap !== null && $spendUsd >= $hardCap;
|
||||
|
||||
$hardStopEnabled = (bool) config('ai-editing.billing.budget.hard_stop_enabled', true);
|
||||
$overrideUntil = Arr::get($tenantSettings, 'ai_editing.budget.override_until');
|
||||
$overrideActive = $this->isOverrideActive($overrideUntil, $now);
|
||||
|
||||
if ($softReached) {
|
||||
$this->emitBudgetAlert($event, 'soft_cap_reached', [
|
||||
'spend_usd' => $spendUsd,
|
||||
'soft_cap_usd' => $softCap,
|
||||
'hard_cap_usd' => $hardCap,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($hardReached) {
|
||||
$this->emitBudgetAlert($event, 'hard_cap_reached', [
|
||||
'spend_usd' => $spendUsd,
|
||||
'soft_cap_usd' => $softCap,
|
||||
'hard_cap_usd' => $hardCap,
|
||||
'hard_stop_enabled' => $hardStopEnabled,
|
||||
'override_active' => $overrideActive,
|
||||
]);
|
||||
}
|
||||
|
||||
$allowed = ! ($hardReached && $hardStopEnabled && ! $overrideActive);
|
||||
|
||||
return [
|
||||
'allowed' => $allowed,
|
||||
'reason_code' => $allowed ? null : 'budget_hard_cap_reached',
|
||||
'message' => $allowed ? null : 'The AI editing budget for this billing period has been exhausted.',
|
||||
'budget' => [
|
||||
'period_start' => $periodStart->toDateString(),
|
||||
'period_end' => $periodEnd->toDateString(),
|
||||
'current_spend_usd' => round($spendUsd, 5),
|
||||
'soft_cap_usd' => $softCap,
|
||||
'hard_cap_usd' => $hardCap,
|
||||
'soft_reached' => $softReached,
|
||||
'hard_reached' => $hardReached,
|
||||
'hard_stop_enabled' => $hardStopEnabled,
|
||||
'override_active' => $overrideActive,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveCap(mixed $tenantValue, mixed $defaultValue): ?float
|
||||
{
|
||||
if (is_numeric($tenantValue)) {
|
||||
$resolved = (float) $tenantValue;
|
||||
|
||||
return $resolved >= 0 ? $resolved : null;
|
||||
}
|
||||
|
||||
if (is_numeric($defaultValue)) {
|
||||
$resolved = (float) $defaultValue;
|
||||
|
||||
return $resolved >= 0 ? $resolved : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function isOverrideActive(mixed $overrideUntil, Carbon $now): bool
|
||||
{
|
||||
if (! is_string($overrideUntil) || trim($overrideUntil) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$deadline = Carbon::parse($overrideUntil);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $deadline->isFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function emitBudgetAlert(Event $event, string $type, array $context): void
|
||||
{
|
||||
$cooldownMinutes = max(1, (int) config('ai-editing.billing.budget.alert_cooldown_minutes', 30));
|
||||
$cacheKey = sprintf('ai-editing:budget-alert:%s:tenant:%d:event:%d', $type, $event->tenant_id, $event->id);
|
||||
|
||||
if (! Cache::add($cacheKey, 1, now()->addMinutes($cooldownMinutes))) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::warning('AI budget threshold reached', array_merge($context, [
|
||||
'type' => $type,
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
]));
|
||||
|
||||
$this->notifyTenant($event, $type, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
private function notifyTenant(Event $event, string $type, array $context): void
|
||||
{
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $event->tenant()->with('user')->first();
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notificationType = $type === 'hard_cap_reached'
|
||||
? 'ai_budget_hard_cap'
|
||||
: 'ai_budget_soft_cap';
|
||||
|
||||
$log = app(TenantNotificationLogger::class)->log($tenant, [
|
||||
'type' => $notificationType,
|
||||
'channel' => 'system',
|
||||
'status' => 'sent',
|
||||
'sent_at' => now(),
|
||||
'context' => [
|
||||
'scope' => 'ai',
|
||||
'threshold' => $type,
|
||||
'event_id' => (int) $event->id,
|
||||
'event_slug' => (string) $event->slug,
|
||||
'event_name' => $this->resolveEventName($event->name),
|
||||
'spend_usd' => round((float) ($context['spend_usd'] ?? 0), 5),
|
||||
'soft_cap_usd' => is_numeric($context['soft_cap_usd'] ?? null) ? (float) $context['soft_cap_usd'] : null,
|
||||
'hard_cap_usd' => is_numeric($context['hard_cap_usd'] ?? null) ? (float) $context['hard_cap_usd'] : null,
|
||||
'hard_stop_enabled' => (bool) ($context['hard_stop_enabled'] ?? config('ai-editing.billing.budget.hard_stop_enabled', true)),
|
||||
'override_active' => (bool) ($context['override_active'] ?? false),
|
||||
],
|
||||
]);
|
||||
|
||||
$userId = (int) ($tenant->user_id ?? 0);
|
||||
if ($userId > 0) {
|
||||
TenantNotificationReceipt::query()->create([
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'notification_log_id' => (int) $log->id,
|
||||
'user_id' => $userId,
|
||||
'status' => 'delivered',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveEventName(mixed $name): ?string
|
||||
{
|
||||
if (is_string($name) && trim($name) !== '') {
|
||||
return trim($name);
|
||||
}
|
||||
|
||||
if (is_array($name)) {
|
||||
foreach ($name as $candidate) {
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return trim($candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
90
app/Services/AiEditing/AiObservabilityService.php
Normal file
90
app/Services/AiEditing/AiObservabilityService.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiObservabilityService
|
||||
{
|
||||
public function recordTerminalOutcome(
|
||||
AiEditRequest $request,
|
||||
string $status,
|
||||
?int $durationMs = null,
|
||||
bool $moderationBlocked = false,
|
||||
string $stage = 'job'
|
||||
): void {
|
||||
$bucket = now()->format('YmdH');
|
||||
$prefix = sprintf('ai-editing:obs:tenant:%d:event:%d:hour:%s', $request->tenant_id, $request->event_id, $bucket);
|
||||
|
||||
Cache::add($prefix.':total', 0, now()->addHours(8));
|
||||
$total = (int) Cache::increment($prefix.':total');
|
||||
|
||||
if ($status === AiEditRequest::STATUS_SUCCEEDED) {
|
||||
Cache::add($prefix.':succeeded', 0, now()->addHours(8));
|
||||
Cache::increment($prefix.':succeeded');
|
||||
} elseif ($status === AiEditRequest::STATUS_BLOCKED) {
|
||||
Cache::add($prefix.':blocked', 0, now()->addHours(8));
|
||||
Cache::increment($prefix.':blocked');
|
||||
} elseif ($status === AiEditRequest::STATUS_FAILED) {
|
||||
Cache::add($prefix.':failed', 0, now()->addHours(8));
|
||||
Cache::increment($prefix.':failed');
|
||||
}
|
||||
|
||||
if ($moderationBlocked) {
|
||||
Cache::add($prefix.':moderation_blocked', 0, now()->addHours(8));
|
||||
Cache::increment($prefix.':moderation_blocked');
|
||||
}
|
||||
|
||||
if (is_int($durationMs) && $durationMs > 0) {
|
||||
Cache::add($prefix.':duration_total_ms', 0, now()->addHours(8));
|
||||
Cache::increment($prefix.':duration_total_ms', $durationMs);
|
||||
|
||||
$latencyWarningMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
|
||||
if ($durationMs >= $latencyWarningMs) {
|
||||
Log::warning('AI provider latency warning', [
|
||||
'tenant_id' => $request->tenant_id,
|
||||
'event_id' => $request->event_id,
|
||||
'request_id' => $request->id,
|
||||
'duration_ms' => $durationMs,
|
||||
'threshold_ms' => $latencyWarningMs,
|
||||
'stage' => $stage,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->checkFailureRateAlert($request, $prefix, $total, $stage);
|
||||
}
|
||||
|
||||
private function checkFailureRateAlert(AiEditRequest $request, string $prefix, int $total, string $stage): void
|
||||
{
|
||||
$minSamples = max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10));
|
||||
if ($total < $minSamples) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failed = (int) (Cache::get($prefix.':failed', 0) ?: 0);
|
||||
$threshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
|
||||
$failureRate = $total > 0 ? ($failed / $total) : 0.0;
|
||||
|
||||
if ($failureRate < $threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cooldownKey = $prefix.':failure_rate_alert';
|
||||
if (! Cache::add($cooldownKey, 1, now()->addMinutes(30))) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::warning('AI failure-rate alert threshold reached', [
|
||||
'tenant_id' => $request->tenant_id,
|
||||
'event_id' => $request->event_id,
|
||||
'failure_rate' => round($failureRate, 5),
|
||||
'failed' => $failed,
|
||||
'total' => $total,
|
||||
'threshold' => $threshold,
|
||||
'stage' => $stage,
|
||||
]);
|
||||
}
|
||||
}
|
||||
164
app/Services/AiEditing/AiStatusNotificationService.php
Normal file
164
app/Services/AiEditing/AiStatusNotificationService.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantNotificationReceipt;
|
||||
use App\Services\GuestNotificationService;
|
||||
use App\Services\Packages\TenantNotificationLogger;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class AiStatusNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GuestNotificationService $guestNotifications,
|
||||
private readonly TenantNotificationLogger $tenantNotificationLogger,
|
||||
) {}
|
||||
|
||||
public function notifyTerminalOutcome(AiEditRequest $request): void
|
||||
{
|
||||
$status = (string) $request->status;
|
||||
if (! in_array($status, [
|
||||
AiEditRequest::STATUS_SUCCEEDED,
|
||||
AiEditRequest::STATUS_FAILED,
|
||||
AiEditRequest::STATUS_BLOCKED,
|
||||
], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->claimLock((int) $request->id, $status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = Event::query()
|
||||
->with('tenant.user')
|
||||
->find((int) $request->event_id);
|
||||
|
||||
if (! $event || ! $event->tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->loadMissing('style');
|
||||
|
||||
$this->notifyTenant($event->tenant, $event, $request, $status);
|
||||
|
||||
if ((int) ($request->requested_by_user_id ?? 0) === 0) {
|
||||
$this->notifyGuest($event, $request, $status);
|
||||
}
|
||||
}
|
||||
|
||||
private function claimLock(int $requestId, string $status): bool
|
||||
{
|
||||
$key = sprintf('ai-editing:terminal-notification:request:%d:status:%s', $requestId, $status);
|
||||
|
||||
return Cache::add($key, 1, now()->addDays(7));
|
||||
}
|
||||
|
||||
private function notifyGuest(Event $event, AiEditRequest $request, string $status): void
|
||||
{
|
||||
[$title, $body] = match ($status) {
|
||||
AiEditRequest::STATUS_SUCCEEDED => [
|
||||
'Dein AI-Magic-Edit ist fertig ✨',
|
||||
'Dein bearbeitetes Foto ist jetzt verfügbar.',
|
||||
],
|
||||
AiEditRequest::STATUS_BLOCKED => [
|
||||
'AI-Magic-Edit wurde blockiert',
|
||||
'Die Bearbeitung wurde durch die Sicherheitsregeln gestoppt.',
|
||||
],
|
||||
default => [
|
||||
'AI-Magic-Edit fehlgeschlagen',
|
||||
'Die Bearbeitung konnte nicht abgeschlossen werden. Bitte erneut versuchen.',
|
||||
],
|
||||
};
|
||||
|
||||
$options = [
|
||||
'payload' => [
|
||||
'photo_id' => (int) $request->photo_id,
|
||||
'count' => 1,
|
||||
],
|
||||
'priority' => $status === AiEditRequest::STATUS_SUCCEEDED ? 2 : 3,
|
||||
'expires_at' => now()->addHours(6),
|
||||
'audience_scope' => GuestNotificationAudience::ALL,
|
||||
];
|
||||
|
||||
$deviceId = trim((string) ($request->requested_by_device_id ?? ''));
|
||||
if ($deviceId !== '') {
|
||||
$options['audience_scope'] = GuestNotificationAudience::GUEST;
|
||||
$options['target_identifier'] = $deviceId;
|
||||
}
|
||||
|
||||
$this->guestNotifications->createNotification(
|
||||
$event,
|
||||
GuestNotificationType::UPLOAD_ALERT,
|
||||
$title,
|
||||
$body,
|
||||
$options
|
||||
);
|
||||
}
|
||||
|
||||
private function notifyTenant(Tenant $tenant, Event $event, AiEditRequest $request, string $status): void
|
||||
{
|
||||
$type = match ($status) {
|
||||
AiEditRequest::STATUS_SUCCEEDED => 'ai_edit_succeeded',
|
||||
AiEditRequest::STATUS_BLOCKED => 'ai_edit_blocked',
|
||||
default => 'ai_edit_failed',
|
||||
};
|
||||
|
||||
$log = $this->tenantNotificationLogger->log($tenant, [
|
||||
'type' => $type,
|
||||
'channel' => 'system',
|
||||
'status' => 'sent',
|
||||
'sent_at' => now(),
|
||||
'context' => [
|
||||
'scope' => 'ai',
|
||||
'status' => $status,
|
||||
'event_id' => (int) $event->id,
|
||||
'event_slug' => (string) $event->slug,
|
||||
'event_name' => $this->resolveEventName($event->name),
|
||||
'request_id' => (int) $request->id,
|
||||
'photo_id' => (int) $request->photo_id,
|
||||
'style_key' => $request->style?->key,
|
||||
'style_name' => $request->style?->name,
|
||||
'failure_code' => $request->failure_code,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->createReceipt($tenant, (int) $log->id);
|
||||
}
|
||||
|
||||
private function createReceipt(Tenant $tenant, int $logId): void
|
||||
{
|
||||
$userId = (int) ($tenant->user_id ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
TenantNotificationReceipt::query()->create([
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'notification_log_id' => $logId,
|
||||
'user_id' => $userId,
|
||||
'status' => 'delivered',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveEventName(mixed $name): ?string
|
||||
{
|
||||
if (is_string($name) && trim($name) !== '') {
|
||||
return trim($name);
|
||||
}
|
||||
|
||||
if (is_array($name)) {
|
||||
foreach ($name as $candidate) {
|
||||
if (is_string($candidate) && trim($candidate) !== '') {
|
||||
return trim($candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
64
app/Services/AiEditing/Safety/AiAbuseEscalationService.php
Normal file
64
app/Services/AiEditing/Safety/AiAbuseEscalationService.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Safety;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AiAbuseEscalationService
|
||||
{
|
||||
public const REASON_CODE = 'abuse_escalation_threshold_reached';
|
||||
|
||||
/**
|
||||
* @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string}
|
||||
*/
|
||||
public function recordPromptBlock(int $tenantId, int $eventId, string $scope): array
|
||||
{
|
||||
return $this->record('prompt_block', $tenantId, $eventId, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string}
|
||||
*/
|
||||
public function recordOutputBlock(int $tenantId, int $eventId, string $scope): array
|
||||
{
|
||||
return $this->record('output_block', $tenantId, $eventId, $scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{type:string,count:int,threshold:int,escalated:bool,reason_code:?string}
|
||||
*/
|
||||
private function record(string $type, int $tenantId, int $eventId, string $scope): array
|
||||
{
|
||||
$threshold = max(1, (int) config('ai-editing.abuse.escalation_threshold_per_hour', 25));
|
||||
$cooldownMinutes = max(1, (int) config('ai-editing.abuse.escalation_cooldown_minutes', 30));
|
||||
$bucket = now()->format('YmdH');
|
||||
|
||||
$counterKey = sprintf('ai-editing:abuse:%s:tenant:%d:event:%d:hour:%s', $type, $tenantId, $eventId, $bucket);
|
||||
Cache::add($counterKey, 0, now()->addHours(2));
|
||||
$count = (int) Cache::increment($counterKey);
|
||||
|
||||
$escalated = $count >= $threshold;
|
||||
if ($escalated) {
|
||||
$cooldownKey = sprintf('ai-editing:abuse:%s:tenant:%d:event:%d:cooldown', $type, $tenantId, $eventId);
|
||||
if (Cache::add($cooldownKey, 1, now()->addMinutes($cooldownMinutes))) {
|
||||
Log::warning('AI abuse escalation threshold reached', [
|
||||
'tenant_id' => $tenantId,
|
||||
'event_id' => $eventId,
|
||||
'type' => $type,
|
||||
'count' => $count,
|
||||
'threshold' => $threshold,
|
||||
'scope_hash' => hash('sha256', $scope),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'count' => $count,
|
||||
'threshold' => $threshold,
|
||||
'escalated' => $escalated,
|
||||
'reason_code' => $escalated ? self::REASON_CODE : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user