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

@@ -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;
}
}

View 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,
]);
}
}

View 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;
}
}

View 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,
];
}
}