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

@@ -7,8 +7,11 @@ use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiImageProviderManager;
use App\Services\AiEditing\AiObservabilityService;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\AiStatusNotificationService;
use App\Services\AiEditing\AiUsageLedgerService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -17,6 +20,8 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
class ProcessAiEditRequest implements ShouldQueue
{
@@ -43,6 +48,9 @@ class ProcessAiEditRequest implements ShouldQueue
public function handle(
AiImageProviderManager $providers,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
@@ -74,43 +82,121 @@ class ProcessAiEditRequest implements ShouldQueue
$result = $providers->forProvider($request->provider)->submit($request);
$this->finalizeProviderRun($providerRun, $result);
$this->applyProviderResult($request->fresh(['outputs']), $result, $safetyPolicy, $runtimeConfig, $usageLedger);
$this->applyProviderResult(
$request->fresh(['outputs']),
$providerRun,
$result,
$safetyPolicy,
$abuseEscalation,
$observability,
$statusNotifications,
$runtimeConfig,
$usageLedger
);
}
public function failed(Throwable $exception): void
{
$request = AiEditRequest::query()->find($this->requestId);
if (! $request) {
return;
}
if (! in_array($request->status, [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING], true)) {
return;
}
$message = trim($exception->getMessage());
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'queue_job_failed',
'failure_message' => $message !== ''
? Str::limit($message, 500, '')
: 'AI edit processing failed in queue.',
'completed_at' => now(),
])->save();
app(AiObservabilityService::class)->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
null,
false,
'process_failed_hook'
);
app(AiStatusNotificationService::class)->notifyTerminalOutcome($request->fresh());
}
private function finalizeProviderRun(AiProviderRun $run, AiProviderResult $result): void
{
$missingTaskId = $result->status === 'processing'
&& (! is_string($result->providerTaskId) || trim($result->providerTaskId) === '');
$status = $missingTaskId
? AiProviderRun::STATUS_FAILED
: ($result->status === 'succeeded'
? AiProviderRun::STATUS_SUCCEEDED
: ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED));
$run->forceFill([
'provider_task_id' => $result->providerTaskId,
'status' => $result->status === 'succeeded' ? AiProviderRun::STATUS_SUCCEEDED : ($result->status === 'processing' ? AiProviderRun::STATUS_RUNNING : AiProviderRun::STATUS_FAILED),
'status' => $status,
'http_status' => $result->httpStatus,
'finished_at' => $result->status === 'processing' ? null : now(),
'finished_at' => $status === AiProviderRun::STATUS_RUNNING ? null : now(),
'duration_ms' => $run->started_at ? (int) max(0, $run->started_at->diffInMilliseconds(now())) : null,
'cost_usd' => $result->costUsd,
'request_payload' => $result->requestPayload,
'response_payload' => $result->responsePayload,
'error_message' => $result->failureMessage,
'error_message' => $missingTaskId
? 'Provider returned processing state without task identifier.'
: $result->failureMessage,
])->save();
}
private function applyProviderResult(
AiEditRequest $request,
AiProviderRun $providerRun,
AiProviderResult $result,
AiSafetyPolicyService $safetyPolicy,
AiAbuseEscalationService $abuseEscalation,
AiObservabilityService $observability,
AiStatusNotificationService $statusNotifications,
AiEditingRuntimeConfig $runtimeConfig,
AiUsageLedgerService $usageLedger
): void {
if ($result->status === 'succeeded') {
$outputDecision = $safetyPolicy->evaluateProviderOutput($result);
if ($outputDecision->blocked) {
$abuseSignal = $abuseEscalation->recordOutputBlock(
(int) $request->tenant_id,
(int) $request->event_id,
'provider:'.$request->provider
);
$safetyReasons = $outputDecision->reasonCodes;
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
$metadata = (array) ($request->metadata ?? []);
$metadata['abuse'] = $abuseSignal;
$request->forceFill([
'status' => AiEditRequest::STATUS_BLOCKED,
'safety_state' => $outputDecision->state,
'safety_reasons' => $outputDecision->reasonCodes,
'safety_reasons' => $safetyReasons,
'failure_code' => $outputDecision->failureCode ?? 'output_policy_blocked',
'failure_message' => $outputDecision->failureMessage,
'metadata' => $metadata,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_BLOCKED,
$providerRun->duration_ms,
true,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
@@ -149,21 +235,49 @@ class ProcessAiEditRequest implements ShouldQueue
'source' => 'process_job',
]);
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_SUCCEEDED,
$providerRun->duration_ms,
false,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
if ($result->status === 'processing') {
$providerTaskId = trim((string) ($result->providerTaskId ?? ''));
if ($providerTaskId === '') {
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'provider_task_id_missing',
'failure_message' => 'Provider returned processing state without a task identifier.',
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
AiEditRequest::STATUS_FAILED,
$providerRun->duration_ms,
false,
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
return;
}
$request->forceFill([
'status' => AiEditRequest::STATUS_PROCESSING,
'failure_code' => null,
'failure_message' => null,
])->save();
if ($result->providerTaskId !== null && $result->providerTaskId !== '') {
PollAiEditRequest::dispatch($request->id, $result->providerTaskId, 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
}
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)
->delay(now()->addSeconds(20))
->onQueue($runtimeConfig->queueName());
return;
}
@@ -176,5 +290,14 @@ class ProcessAiEditRequest implements ShouldQueue
'failure_message' => $result->failureMessage,
'completed_at' => now(),
])->save();
$observability->recordTerminalOutcome(
$request,
$result->status === 'blocked' ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_FAILED,
$providerRun->duration_ms,
$result->status === 'blocked',
'process'
);
$statusNotifications->notifyTerminalOutcome($request->fresh());
}
}