feat: implement AI styling foundation and billing scope rework
This commit is contained in:
65
app/Services/AiEditing/AiEditingRuntimeConfig.php
Normal file
65
app/Services/AiEditing/AiEditingRuntimeConfig.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiEditingSetting;
|
||||
|
||||
class AiEditingRuntimeConfig
|
||||
{
|
||||
public function settings(): AiEditingSetting
|
||||
{
|
||||
return AiEditingSetting::current();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (bool) $this->settings()->is_enabled;
|
||||
}
|
||||
|
||||
public function defaultProvider(): string
|
||||
{
|
||||
return (string) ($this->settings()->default_provider ?: 'runware');
|
||||
}
|
||||
|
||||
public function queueAutoDispatch(): bool
|
||||
{
|
||||
return (bool) $this->settings()->queue_auto_dispatch;
|
||||
}
|
||||
|
||||
public function queueName(): string
|
||||
{
|
||||
$queueName = trim((string) ($this->settings()->queue_name ?: ''));
|
||||
|
||||
return $queueName !== '' ? $queueName : 'default';
|
||||
}
|
||||
|
||||
public function maxPolls(): int
|
||||
{
|
||||
return max(1, (int) $this->settings()->queue_max_polls);
|
||||
}
|
||||
|
||||
public function runwareMode(): string
|
||||
{
|
||||
$mode = trim((string) ($this->settings()->runware_mode ?: ''));
|
||||
|
||||
return $mode !== '' ? $mode : 'live';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function blockedTerms(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $term): string => trim((string) $term),
|
||||
(array) $this->settings()->blocked_terms
|
||||
)));
|
||||
}
|
||||
|
||||
public function statusMessage(): ?string
|
||||
{
|
||||
$message = trim((string) ($this->settings()->status_message ?? ''));
|
||||
|
||||
return $message !== '' ? $message : null;
|
||||
}
|
||||
}
|
||||
18
app/Services/AiEditing/AiImageProviderManager.php
Normal file
18
app/Services/AiEditing/AiImageProviderManager.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Services\AiEditing\Contracts\AiImageProvider;
|
||||
use App\Services\AiEditing\Providers\NullAiImageProvider;
|
||||
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
|
||||
|
||||
class AiImageProviderManager
|
||||
{
|
||||
public function forProvider(string $provider): AiImageProvider
|
||||
{
|
||||
return match ($provider) {
|
||||
'runware' => app(RunwareAiImageProvider::class),
|
||||
default => app(NullAiImageProvider::class),
|
||||
};
|
||||
}
|
||||
}
|
||||
118
app/Services/AiEditing/AiProviderResult.php
Normal file
118
app/Services/AiEditing/AiProviderResult.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
class AiProviderResult
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $outputs
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly ?string $providerTaskId = null,
|
||||
public readonly ?string $failureCode = null,
|
||||
public readonly ?string $failureMessage = null,
|
||||
public readonly ?string $safetyState = null,
|
||||
public readonly array $safetyReasons = [],
|
||||
public readonly ?float $costUsd = null,
|
||||
public readonly array $outputs = [],
|
||||
public readonly array $requestPayload = [],
|
||||
public readonly array $responsePayload = [],
|
||||
public readonly ?int $httpStatus = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $outputs
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function succeeded(
|
||||
array $outputs = [],
|
||||
?float $costUsd = null,
|
||||
?string $safetyState = null,
|
||||
array $safetyReasons = [],
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'succeeded',
|
||||
outputs: $outputs,
|
||||
costUsd: $costUsd,
|
||||
safetyState: $safetyState,
|
||||
safetyReasons: $safetyReasons,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function processing(
|
||||
string $providerTaskId,
|
||||
?float $costUsd = null,
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'processing',
|
||||
providerTaskId: $providerTaskId,
|
||||
costUsd: $costUsd,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function failed(
|
||||
string $failureCode,
|
||||
string $failureMessage,
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'failed',
|
||||
failureCode: $failureCode,
|
||||
failureMessage: $failureMessage,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $requestPayload
|
||||
* @param array<string, mixed> $responsePayload
|
||||
*/
|
||||
public static function blocked(
|
||||
string $failureCode,
|
||||
string $failureMessage,
|
||||
?string $safetyState = null,
|
||||
array $safetyReasons = [],
|
||||
array $requestPayload = [],
|
||||
array $responsePayload = [],
|
||||
?int $httpStatus = null,
|
||||
): self {
|
||||
return new self(
|
||||
status: 'blocked',
|
||||
failureCode: $failureCode,
|
||||
failureMessage: $failureMessage,
|
||||
safetyState: $safetyState,
|
||||
safetyReasons: $safetyReasons,
|
||||
requestPayload: $requestPayload,
|
||||
responsePayload: $responsePayload,
|
||||
httpStatus: $httpStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
134
app/Services/AiEditing/AiStyleAccessService.php
Normal file
134
app/Services/AiEditing/AiStyleAccessService.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AiStyleAccessService
|
||||
{
|
||||
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
|
||||
|
||||
public function canUseStyle(Event $event, AiStyle $style): bool
|
||||
{
|
||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
||||
if (! $entitlement['allowed']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedSources = $this->allowedSources($style);
|
||||
if (! in_array((string) $entitlement['granted_by'], $allowedSources, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requiredPackageFeatures = $this->requiredPackageFeatures($style);
|
||||
if ($requiredPackageFeatures === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$packageFeatures = $this->resolveEventPackageFeatures($event);
|
||||
|
||||
foreach ($requiredPackageFeatures as $requiredFeature) {
|
||||
if (! in_array($requiredFeature, $packageFeatures, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, AiStyle> $styles
|
||||
* @return Collection<int, AiStyle>
|
||||
*/
|
||||
public function filterStylesForEvent(Event $event, Collection $styles): Collection
|
||||
{
|
||||
return $styles
|
||||
->filter(fn (AiStyle $style): bool => $this->canUseStyle($event, $style))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function allowedSources(AiStyle $style): array
|
||||
{
|
||||
$metadataSources = $this->normalizeStringList(Arr::get($style->metadata ?? [], 'entitlements.allowed_sources', []));
|
||||
if ($metadataSources !== []) {
|
||||
return $metadataSources;
|
||||
}
|
||||
|
||||
if (is_bool(Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon'))) {
|
||||
return Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon')
|
||||
? ['package', 'addon']
|
||||
: ['package'];
|
||||
}
|
||||
|
||||
return $style->is_premium ? ['package'] : ['package', 'addon'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function requiredPackageFeatures(AiStyle $style): array
|
||||
{
|
||||
return $this->normalizeStringList(
|
||||
Arr::get($style->metadata ?? [], 'entitlements.required_package_features', [])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveEventPackageFeatures(Event $event): array
|
||||
{
|
||||
$eventPackage = $event->relationLoaded('eventPackage') && $event->eventPackage
|
||||
? $event->eventPackage
|
||||
: $event->eventPackage()->with('package')->first();
|
||||
|
||||
if (! $eventPackage instanceof EventPackage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$package = $eventPackage->relationLoaded('package') ? $eventPackage->package : $eventPackage->package()->first();
|
||||
|
||||
return $this->normalizeFeatureList($package?->features);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeFeatureList(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$value = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return $this->normalizeStringList($value);
|
||||
}
|
||||
|
||||
return $this->normalizeStringList(array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeStringList(array $values): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(array_map(
|
||||
static fn (mixed $value): string => trim((string) $value),
|
||||
$values
|
||||
))));
|
||||
}
|
||||
}
|
||||
209
app/Services/AiEditing/AiStylingEntitlementService.php
Normal file
209
app/Services/AiEditing/AiStylingEntitlementService.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AiStylingEntitlementService
|
||||
{
|
||||
public function packageFeatureKey(): string
|
||||
{
|
||||
$featureKey = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling'));
|
||||
|
||||
return $featureKey !== '' ? $featureKey : 'ai_styling';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function addonKeys(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $key): string => trim((string) $key),
|
||||
(array) config('ai-editing.entitlements.addon_keys', ['ai_styling_unlock'])
|
||||
)));
|
||||
}
|
||||
|
||||
public function lockedMessage(): string
|
||||
{
|
||||
$message = trim((string) config('ai-editing.entitlements.locked_message', ''));
|
||||
|
||||
if ($message !== '') {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return 'AI editing requires the Premium package or the AI Styling add-on.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* allowed: bool,
|
||||
* granted_by: 'package'|'addon'|null,
|
||||
* required_feature: string,
|
||||
* addon_keys: array<int, string>
|
||||
* }
|
||||
*/
|
||||
public function resolveForEvent(Event $event): array
|
||||
{
|
||||
$requiredFeature = $this->packageFeatureKey();
|
||||
$addonKeys = $this->addonKeys();
|
||||
$eventPackage = $this->resolveEventPackage($event);
|
||||
|
||||
if (! $eventPackage) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'granted_by' => null,
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->packageGrantsAccess($eventPackage->package, $requiredFeature)) {
|
||||
return [
|
||||
'allowed' => true,
|
||||
'granted_by' => 'package',
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->addonGrantsAccess($eventPackage, $addonKeys, $requiredFeature)) {
|
||||
return [
|
||||
'allowed' => true,
|
||||
'granted_by' => 'addon',
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
'granted_by' => null,
|
||||
'required_feature' => $requiredFeature,
|
||||
'addon_keys' => $addonKeys,
|
||||
];
|
||||
}
|
||||
|
||||
public function hasAccessForEvent(Event $event): bool
|
||||
{
|
||||
return (bool) $this->resolveForEvent($event)['allowed'];
|
||||
}
|
||||
|
||||
private function resolveEventPackage(Event $event): ?EventPackage
|
||||
{
|
||||
$event->loadMissing('eventPackage.package', 'eventPackage.addons');
|
||||
|
||||
if ($event->eventPackage) {
|
||||
return $event->eventPackage;
|
||||
}
|
||||
|
||||
return $event->eventPackages()
|
||||
->with(['package', 'addons'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function packageGrantsAccess(?Package $package, string $requiredFeature): bool
|
||||
{
|
||||
if (! $package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $addonKeys
|
||||
*/
|
||||
private function addonGrantsAccess(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool
|
||||
{
|
||||
$addons = $eventPackage->relationLoaded('addons')
|
||||
? $eventPackage->addons
|
||||
: $eventPackage->addons()
|
||||
->where('status', 'completed')
|
||||
->get();
|
||||
|
||||
return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool {
|
||||
if (! $this->addonIsActive($addon)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$metadataFeatures = $this->normalizeFeatureList(
|
||||
Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', []))
|
||||
);
|
||||
|
||||
return in_array($requiredFeature, $metadataFeatures, true);
|
||||
});
|
||||
}
|
||||
|
||||
private function addonIsActive(EventPackageAddon $addon): bool
|
||||
{
|
||||
if ($addon->status !== 'completed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$expiryCandidates = [
|
||||
Arr::get($addon->metadata ?? [], 'entitlements.expires_at'),
|
||||
Arr::get($addon->metadata ?? [], 'expires_at'),
|
||||
Arr::get($addon->metadata ?? [], 'valid_until'),
|
||||
];
|
||||
|
||||
foreach ($expiryCandidates as $candidate) {
|
||||
if (! is_string($candidate) || trim($candidate) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$expiresAt = CarbonImmutable::parse($candidate);
|
||||
} catch (\Throwable) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($expiresAt->isPast()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeFeatureList(mixed $value): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$value = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (array_is_list($value)) {
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $feature): string => trim((string) $feature),
|
||||
$value
|
||||
)));
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (mixed $feature): string => trim((string) $feature),
|
||||
array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled))
|
||||
)));
|
||||
}
|
||||
}
|
||||
67
app/Services/AiEditing/AiUsageLedgerService.php
Normal file
67
app/Services/AiEditing/AiUsageLedgerService.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Models\AiUsageLedger;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AiUsageLedgerService
|
||||
{
|
||||
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function recordDebitForRequest(AiEditRequest $request, ?float $costUsd = null, array $metadata = []): AiUsageLedger
|
||||
{
|
||||
return DB::transaction(function () use ($request, $costUsd, $metadata): AiUsageLedger {
|
||||
$lockedRequest = AiEditRequest::query()
|
||||
->whereKey($request->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$existing = AiUsageLedger::query()
|
||||
->where('request_id', $lockedRequest->id)
|
||||
->where('entry_type', AiUsageLedger::TYPE_DEBIT)
|
||||
->first();
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$resolvedCost = $costUsd;
|
||||
if ($resolvedCost === null || $resolvedCost < 0) {
|
||||
$resolvedCost = (float) config('ai-editing.billing.default_unit_cost_usd', 0.01);
|
||||
}
|
||||
|
||||
$event = Event::query()->find($lockedRequest->event_id);
|
||||
$entitlement = $event ? $this->entitlements->resolveForEvent($event) : [
|
||||
'allowed' => false,
|
||||
'granted_by' => null,
|
||||
];
|
||||
|
||||
$packageContext = $entitlement['granted_by'] === 'package'
|
||||
? 'package_included'
|
||||
: ($entitlement['granted_by'] === 'addon' ? 'addon_unlock' : 'unentitled');
|
||||
|
||||
return AiUsageLedger::query()->create([
|
||||
'tenant_id' => $lockedRequest->tenant_id,
|
||||
'event_id' => $lockedRequest->event_id,
|
||||
'request_id' => $lockedRequest->id,
|
||||
'entry_type' => AiUsageLedger::TYPE_DEBIT,
|
||||
'quantity' => 1,
|
||||
'unit_cost_usd' => $resolvedCost,
|
||||
'amount_usd' => $resolvedCost,
|
||||
'currency' => 'USD',
|
||||
'package_context' => $packageContext,
|
||||
'recorded_at' => now(),
|
||||
'metadata' => array_merge([
|
||||
'provider' => $lockedRequest->provider,
|
||||
'provider_model' => $lockedRequest->provider_model,
|
||||
'granted_by' => $entitlement['granted_by'],
|
||||
], $metadata),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
13
app/Services/AiEditing/Contracts/AiImageProvider.php
Normal file
13
app/Services/AiEditing/Contracts/AiImageProvider.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Contracts;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
|
||||
interface AiImageProvider
|
||||
{
|
||||
public function submit(AiEditRequest $request): AiProviderResult;
|
||||
|
||||
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult;
|
||||
}
|
||||
90
app/Services/AiEditing/EventAiEditingPolicyService.php
Normal file
90
app/Services/AiEditing/EventAiEditingPolicyService.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing;
|
||||
|
||||
use App\Models\AiStyle;
|
||||
use App\Models\Event;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventAiEditingPolicyService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* enabled: bool,
|
||||
* allow_custom_prompt: bool,
|
||||
* allowed_style_keys: array<int, string>,
|
||||
* policy_message: ?string
|
||||
* }
|
||||
*/
|
||||
public function resolve(Event $event): array
|
||||
{
|
||||
$settings = is_array($event->settings) ? $event->settings : [];
|
||||
$aiSettings = Arr::get($settings, 'ai_editing', []);
|
||||
$aiSettings = is_array($aiSettings) ? $aiSettings : [];
|
||||
|
||||
$enabled = array_key_exists('enabled', $aiSettings)
|
||||
? (bool) $aiSettings['enabled']
|
||||
: true;
|
||||
$allowCustomPrompt = array_key_exists('allow_custom_prompt', $aiSettings)
|
||||
? (bool) $aiSettings['allow_custom_prompt']
|
||||
: true;
|
||||
|
||||
$allowedStyleKeys = collect($aiSettings['allowed_style_keys'] ?? [])
|
||||
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
|
||||
->map(fn (string $value): string => trim($value))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$policyMessage = trim((string) ($aiSettings['policy_message'] ?? ''));
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'allow_custom_prompt' => $allowCustomPrompt,
|
||||
'allowed_style_keys' => $allowedStyleKeys,
|
||||
'policy_message' => $policyMessage !== '' ? $policyMessage : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function isEnabled(Event $event): bool
|
||||
{
|
||||
return (bool) $this->resolve($event)['enabled'];
|
||||
}
|
||||
|
||||
public function isStyleAllowed(Event $event, ?AiStyle $style): bool
|
||||
{
|
||||
$policy = $this->resolve($event);
|
||||
|
||||
if ($style === null) {
|
||||
return (bool) $policy['allow_custom_prompt'];
|
||||
}
|
||||
|
||||
/** @var array<int, string> $allowedStyleKeys */
|
||||
$allowedStyleKeys = $policy['allowed_style_keys'];
|
||||
if ($allowedStyleKeys === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($style->key, $allowedStyleKeys, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, AiStyle> $styles
|
||||
* @return Collection<int, AiStyle>
|
||||
*/
|
||||
public function filterStyles(Event $event, Collection $styles): Collection
|
||||
{
|
||||
$policy = $this->resolve($event);
|
||||
|
||||
/** @var array<int, string> $allowedStyleKeys */
|
||||
$allowedStyleKeys = $policy['allowed_style_keys'];
|
||||
if ($allowedStyleKeys === []) {
|
||||
return $styles->values();
|
||||
}
|
||||
|
||||
return $styles
|
||||
->filter(fn (AiStyle $style): bool => in_array($style->key, $allowedStyleKeys, true))
|
||||
->values();
|
||||
}
|
||||
}
|
||||
26
app/Services/AiEditing/Providers/NullAiImageProvider.php
Normal file
26
app/Services/AiEditing/Providers/NullAiImageProvider.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Providers;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\Contracts\AiImageProvider;
|
||||
|
||||
class NullAiImageProvider implements AiImageProvider
|
||||
{
|
||||
public function submit(AiEditRequest $request): AiProviderResult
|
||||
{
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_supported',
|
||||
sprintf('The AI provider "%s" is not supported.', $request->provider)
|
||||
);
|
||||
}
|
||||
|
||||
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult
|
||||
{
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_supported',
|
||||
sprintf('The AI provider "%s" is not supported.', $request->provider)
|
||||
);
|
||||
}
|
||||
}
|
||||
287
app/Services/AiEditing/Providers/RunwareAiImageProvider.php
Normal file
287
app/Services/AiEditing/Providers/RunwareAiImageProvider.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Providers;
|
||||
|
||||
use App\Models\AiEditRequest;
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use App\Services\AiEditing\Contracts\AiImageProvider;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class RunwareAiImageProvider implements AiImageProvider
|
||||
{
|
||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
|
||||
|
||||
public function submit(AiEditRequest $request): AiProviderResult
|
||||
{
|
||||
if ($this->isFakeMode()) {
|
||||
return $this->fakeResult($request);
|
||||
}
|
||||
|
||||
$apiKey = $this->apiKey();
|
||||
if (! $apiKey) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_configured',
|
||||
'Runware API key is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
[
|
||||
'taskType' => 'imageInference',
|
||||
'taskUUID' => (string) Str::uuid(),
|
||||
'positivePrompt' => (string) ($request->prompt ?? ''),
|
||||
'negativePrompt' => (string) ($request->negative_prompt ?? ''),
|
||||
'outputType' => 'URL',
|
||||
'outputFormat' => 'JPG',
|
||||
'includeCost' => true,
|
||||
'safety' => [
|
||||
'checkContent' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
if (is_string($request->provider_model) && $request->provider_model !== '') {
|
||||
$payload[0]['model'] = $request->provider_model;
|
||||
}
|
||||
|
||||
if (is_string($request->input_image_path) && $request->input_image_path !== '') {
|
||||
$payload[0]['seedImage'] = $request->input_image_path;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withToken($apiKey)
|
||||
->acceptJson()
|
||||
->timeout((int) config('services.runware.timeout', 90))
|
||||
->post($this->baseUrl(), $payload);
|
||||
|
||||
$body = (array) $response->json();
|
||||
$data = Arr::first((array) ($body['data'] ?? []), []);
|
||||
$providerTaskId = (string) ($data['taskUUID'] ?? '');
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
|
||||
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
|
||||
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
|
||||
|
||||
if (is_string($imageUrl) && $imageUrl !== '') {
|
||||
if ($providerNsfw) {
|
||||
return AiProviderResult::blocked(
|
||||
failureCode: 'provider_nsfw_content',
|
||||
failureMessage: 'Provider flagged generated content as unsafe.',
|
||||
safetyState: 'blocked',
|
||||
safetyReasons: ['provider_nsfw_content'],
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::succeeded(
|
||||
outputs: [[
|
||||
'provider_url' => $imageUrl,
|
||||
'provider_asset_id' => $providerTaskId !== '' ? $providerTaskId : null,
|
||||
'mime_type' => 'image/jpeg',
|
||||
]],
|
||||
costUsd: $cost,
|
||||
safetyState: 'passed',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($providerTaskId !== '' || $status === 'processing') {
|
||||
return AiProviderResult::processing(
|
||||
providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(),
|
||||
costUsd: $cost,
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::failed(
|
||||
'provider_unexpected_response',
|
||||
'Runware returned an unexpected response format.',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_exception',
|
||||
$exception->getMessage(),
|
||||
requestPayload: ['tasks' => $payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult
|
||||
{
|
||||
if ($this->isFakeMode()) {
|
||||
return $this->fakeResult($request, $providerTaskId);
|
||||
}
|
||||
|
||||
$apiKey = $this->apiKey();
|
||||
if (! $apiKey) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_not_configured',
|
||||
'Runware API key is not configured.'
|
||||
);
|
||||
}
|
||||
|
||||
$payload = [[
|
||||
'taskType' => 'getResponse',
|
||||
'taskUUID' => $providerTaskId,
|
||||
'includeCost' => true,
|
||||
]];
|
||||
|
||||
try {
|
||||
$response = Http::withToken($apiKey)
|
||||
->acceptJson()
|
||||
->timeout((int) config('services.runware.timeout', 90))
|
||||
->post($this->baseUrl(), $payload);
|
||||
|
||||
$body = (array) $response->json();
|
||||
$data = Arr::first((array) ($body['data'] ?? []), []);
|
||||
$status = strtolower((string) ($data['status'] ?? ''));
|
||||
$cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null;
|
||||
$imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null;
|
||||
$providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null);
|
||||
|
||||
if (is_string($imageUrl) && $imageUrl !== '') {
|
||||
if ($providerNsfw) {
|
||||
return AiProviderResult::blocked(
|
||||
failureCode: 'provider_nsfw_content',
|
||||
failureMessage: 'Provider flagged generated content as unsafe.',
|
||||
safetyState: 'blocked',
|
||||
safetyReasons: ['provider_nsfw_content'],
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::succeeded(
|
||||
outputs: [[
|
||||
'provider_url' => $imageUrl,
|
||||
'provider_asset_id' => $providerTaskId,
|
||||
'mime_type' => 'image/jpeg',
|
||||
]],
|
||||
costUsd: $cost,
|
||||
safetyState: 'passed',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 'processing' || $status === '') {
|
||||
return AiProviderResult::processing(
|
||||
providerTaskId: $providerTaskId,
|
||||
costUsd: $cost,
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($status, ['failed', 'error'], true)) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_failed',
|
||||
(string) ($data['errorMessage'] ?? 'Runware reported a failed job.'),
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
return AiProviderResult::failed(
|
||||
'provider_unexpected_response',
|
||||
'Runware returned an unexpected poll response format.',
|
||||
requestPayload: ['tasks' => $payload],
|
||||
responsePayload: $body,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
return AiProviderResult::failed(
|
||||
'provider_exception',
|
||||
$exception->getMessage(),
|
||||
requestPayload: ['tasks' => $payload],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function fakeResult(AiEditRequest $request, ?string $taskId = null): AiProviderResult
|
||||
{
|
||||
$resolvedTaskId = $taskId ?: 'runware-fake-'.Str::uuid()->toString();
|
||||
$fakeNsfw = (bool) Arr::get($request->metadata ?? [], 'fake_nsfw', false);
|
||||
|
||||
return AiProviderResult::succeeded(
|
||||
outputs: [[
|
||||
'provider_url' => sprintf('https://cdn.example.invalid/ai/%s.jpg', $resolvedTaskId),
|
||||
'provider_asset_id' => $resolvedTaskId,
|
||||
'mime_type' => 'image/jpeg',
|
||||
'width' => 1024,
|
||||
'height' => 1024,
|
||||
]],
|
||||
costUsd: 0.01,
|
||||
safetyState: $fakeNsfw ? 'blocked' : 'passed',
|
||||
safetyReasons: $fakeNsfw ? ['provider_nsfw_content'] : [],
|
||||
requestPayload: [
|
||||
'prompt' => $request->prompt,
|
||||
'provider_model' => $request->provider_model,
|
||||
'task_id' => $resolvedTaskId,
|
||||
],
|
||||
responsePayload: [
|
||||
'mode' => 'fake',
|
||||
'status' => 'succeeded',
|
||||
'data' => [
|
||||
[
|
||||
'taskUUID' => $resolvedTaskId,
|
||||
'NSFWContent' => $fakeNsfw,
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function isFakeMode(): bool
|
||||
{
|
||||
return $this->runtimeConfig->runwareMode() === 'fake';
|
||||
}
|
||||
|
||||
private function apiKey(): ?string
|
||||
{
|
||||
$apiKey = config('services.runware.api_key');
|
||||
|
||||
return is_string($apiKey) && $apiKey !== '' ? $apiKey : null;
|
||||
}
|
||||
|
||||
private function baseUrl(): string
|
||||
{
|
||||
$base = (string) config('services.runware.base_url', 'https://api.runware.ai/v1');
|
||||
|
||||
return rtrim($base, '/');
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value === 1;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
39
app/Services/AiEditing/Safety/AiSafetyDecision.php
Normal file
39
app/Services/AiEditing/Safety/AiSafetyDecision.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Safety;
|
||||
|
||||
class AiSafetyDecision
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $reasonCodes
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly bool $blocked,
|
||||
public readonly string $state,
|
||||
public readonly array $reasonCodes = [],
|
||||
public readonly ?string $failureCode = null,
|
||||
public readonly ?string $failureMessage = null,
|
||||
) {}
|
||||
|
||||
public static function passed(): self
|
||||
{
|
||||
return new self(
|
||||
blocked: false,
|
||||
state: 'passed',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $reasonCodes
|
||||
*/
|
||||
public static function blocked(array $reasonCodes, string $failureCode, string $failureMessage): self
|
||||
{
|
||||
return new self(
|
||||
blocked: true,
|
||||
state: 'blocked',
|
||||
reasonCodes: array_values(array_unique($reasonCodes)),
|
||||
failureCode: $failureCode,
|
||||
failureMessage: $failureMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
100
app/Services/AiEditing/Safety/AiSafetyPolicyService.php
Normal file
100
app/Services/AiEditing/Safety/AiSafetyPolicyService.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\AiEditing\Safety;
|
||||
|
||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
||||
use App\Services\AiEditing\AiProviderResult;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AiSafetyPolicyService
|
||||
{
|
||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
|
||||
|
||||
public function evaluatePrompt(?string $prompt, ?string $negativePrompt): AiSafetyDecision
|
||||
{
|
||||
$blockedTerms = array_filter(array_map(
|
||||
static fn (mixed $term): string => Str::lower(trim((string) $term)),
|
||||
$this->runtimeConfig->blockedTerms()
|
||||
));
|
||||
|
||||
if ($blockedTerms === []) {
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
$fullPrompt = Str::lower(trim(sprintf('%s %s', (string) $prompt, (string) $negativePrompt)));
|
||||
if ($fullPrompt === '') {
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
foreach ($blockedTerms as $term) {
|
||||
if ($term !== '' && str_contains($fullPrompt, $term)) {
|
||||
$matched[] = 'prompt_blocked_term';
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === []) {
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: $matched,
|
||||
failureCode: 'prompt_policy_blocked',
|
||||
failureMessage: 'The provided prompt violates the AI editing safety policy.'
|
||||
);
|
||||
}
|
||||
|
||||
public function evaluateProviderOutput(AiProviderResult $result): AiSafetyDecision
|
||||
{
|
||||
if ($result->status === 'blocked') {
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_blocked'],
|
||||
failureCode: $result->failureCode ?: 'output_policy_blocked',
|
||||
failureMessage: $result->failureMessage ?: 'The generated output was blocked by safety policy.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($result->safetyState === 'blocked') {
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_nsfw_content'],
|
||||
failureCode: 'output_policy_blocked',
|
||||
failureMessage: 'The generated output was blocked by safety policy.'
|
||||
);
|
||||
}
|
||||
|
||||
$payloadItems = (array) Arr::get($result->responsePayload, 'data', []);
|
||||
foreach ($payloadItems as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->toBool(Arr::get($item, 'NSFWContent')) || $this->toBool(Arr::get($item, 'nsfwContent'))) {
|
||||
return AiSafetyDecision::blocked(
|
||||
reasonCodes: ['provider_nsfw_content'],
|
||||
failureCode: 'output_policy_blocked',
|
||||
failureMessage: 'The generated output was blocked by safety policy.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return AiSafetyDecision::passed();
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value === 1;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user