219 lines
8.6 KiB
PHP
219 lines
8.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Maturity;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkMaturityAuditFinding;
|
|
use Illuminate\Contracts\Auth\Authenticatable;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class ArtworkMaturityAuditService
|
|
{
|
|
public function eligibleArtworkQuery(bool $includeExistingOpenFindings = false): Builder
|
|
{
|
|
$query = Artwork::query()
|
|
->whereNotNull('hash')
|
|
->whereNotNull('thumb_ext')
|
|
->whereRaw('TRIM(hash) != ?',[ '' ])
|
|
->whereRaw('TRIM(thumb_ext) != ?',[ '' ]);
|
|
|
|
$this->applyLegacyUnsetFilter($query);
|
|
|
|
if (! $includeExistingOpenFindings && Schema::hasTable('artwork_maturity_audit_findings')) {
|
|
$query->whereDoesntHave('maturityAuditFinding', function (Builder $finding): void {
|
|
$finding->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN);
|
|
});
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
public function openFindingsQuery(): Builder
|
|
{
|
|
return ArtworkMaturityAuditFinding::query()
|
|
->with(['artwork.user.profile', 'artwork.group', 'artwork.categories.contentType'])
|
|
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
|
|
->whereHas('artwork', function (Builder $query): void {
|
|
$this->applyLegacyUnsetFilter($query);
|
|
});
|
|
}
|
|
|
|
public function openFindingsCount(): int
|
|
{
|
|
if (! Schema::hasTable('artwork_maturity_audit_findings')) {
|
|
return 0;
|
|
}
|
|
|
|
return (int) $this->openFindingsQuery()->count();
|
|
}
|
|
|
|
public function isArtworkEligible(Artwork $artwork): bool
|
|
{
|
|
return ! (bool) $artwork->is_mature
|
|
&& in_array((string) ($artwork->maturity_level ?? ArtworkMaturityService::LEVEL_SAFE), ['', ArtworkMaturityService::LEVEL_SAFE], true)
|
|
&& in_array((string) ($artwork->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR), ['', ArtworkMaturityService::STATUS_CLEAR], true)
|
|
&& in_array((string) ($artwork->maturity_source ?? ArtworkMaturityService::SOURCE_LEGACY), ['', ArtworkMaturityService::SOURCE_LEGACY], true)
|
|
&& $artwork->maturity_declared_at === null
|
|
&& $artwork->maturity_reviewed_at === null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $assessment
|
|
*/
|
|
public function shouldOpenFinding(array $assessment): bool
|
|
{
|
|
$status = Str::lower(trim((string) ($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED)));
|
|
if ($status !== ArtworkMaturityService::AI_STATUS_SUCCEEDED) {
|
|
return false;
|
|
}
|
|
|
|
$actionHint = Str::lower(trim((string) ($assessment['action_hint'] ?? '')));
|
|
if (in_array($actionHint, [ArtworkMaturityService::AI_ACTION_REVIEW, ArtworkMaturityService::AI_ACTION_FLAG_HIGH], true)) {
|
|
return true;
|
|
}
|
|
|
|
$label = Str::lower(trim((string) ($assessment['maturity_label'] ?? '')));
|
|
$confidence = is_numeric($assessment['confidence'] ?? null) ? (float) $assessment['confidence'] : 0.0;
|
|
|
|
return $label === ArtworkMaturityService::LEVEL_MATURE
|
|
&& $confidence >= (float) config('maturity.ai.threshold', 0.68);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $assessment
|
|
*/
|
|
public function recordFinding(Artwork $artwork, array $assessment, string $thumbnailVariant): ArtworkMaturityAuditFinding
|
|
{
|
|
$finding = ArtworkMaturityAuditFinding::query()->updateOrCreate(
|
|
['artwork_id' => (int) $artwork->id],
|
|
[
|
|
'status' => ArtworkMaturityAuditFinding::STATUS_OPEN,
|
|
'thumbnail_variant' => $thumbnailVariant,
|
|
'ai_label' => $this->nullableLowerString($assessment['maturity_label'] ?? null),
|
|
'ai_confidence' => $this->nullableFloat($assessment['confidence'] ?? null),
|
|
'ai_score' => $this->nullableFloat($assessment['score'] ?? ($assessment['confidence'] ?? null)),
|
|
'ai_labels' => $this->normalizeLabels($assessment['labels'] ?? []),
|
|
'ai_model' => $this->nullableString($assessment['model'] ?? null),
|
|
'ai_threshold_used' => $this->nullableFloat($assessment['threshold_used'] ?? null),
|
|
'ai_analysis_time_ms' => is_numeric($assessment['analysis_time_ms'] ?? null) ? (int) $assessment['analysis_time_ms'] : null,
|
|
'ai_action_hint' => $this->nullableLowerString($assessment['action_hint'] ?? null),
|
|
'ai_status' => $this->nullableLowerString($assessment['status'] ?? ArtworkMaturityService::AI_STATUS_FAILED) ?? ArtworkMaturityService::AI_STATUS_FAILED,
|
|
'ai_advisory' => $this->nullableString($assessment['advisory'] ?? null),
|
|
'detected_at' => now(),
|
|
'last_scanned_at' => now(),
|
|
'resolution_action' => null,
|
|
'resolution_note' => null,
|
|
'resolved_by' => null,
|
|
'resolved_at' => null,
|
|
],
|
|
);
|
|
|
|
return $finding->fresh(['artwork']);
|
|
}
|
|
|
|
public function markFindingCleared(Artwork $artwork, ?string $note = null): void
|
|
{
|
|
ArtworkMaturityAuditFinding::query()
|
|
->where('artwork_id', (int) $artwork->id)
|
|
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
|
|
->update([
|
|
'status' => ArtworkMaturityAuditFinding::STATUS_CLEARED,
|
|
'resolution_action' => 'auto_cleared',
|
|
'resolution_note' => $note,
|
|
'resolved_at' => now(),
|
|
'last_scanned_at' => now(),
|
|
]);
|
|
}
|
|
|
|
public function resolveFindingForReview(Artwork $artwork, Authenticatable $moderator, string $action, ?string $note = null): void
|
|
{
|
|
$moderatorId = (int) $moderator->getAuthIdentifier();
|
|
|
|
ArtworkMaturityAuditFinding::query()
|
|
->where('artwork_id', (int) $artwork->id)
|
|
->where('status', ArtworkMaturityAuditFinding::STATUS_OPEN)
|
|
->update([
|
|
'status' => ArtworkMaturityAuditFinding::STATUS_REVIEWED,
|
|
'resolution_action' => Str::lower(trim($action)),
|
|
'resolution_note' => $note,
|
|
'resolved_by' => $moderatorId,
|
|
'resolved_at' => now(),
|
|
'last_scanned_at' => now(),
|
|
]);
|
|
}
|
|
|
|
private function applyLegacyUnsetFilter(Builder $query): Builder
|
|
{
|
|
return $query
|
|
->where(function (Builder $builder): void {
|
|
$builder->whereNull('maturity_declared_at')
|
|
->whereNull('maturity_reviewed_at')
|
|
->where(function (Builder $state): void {
|
|
$state->whereNull('maturity_source')
|
|
->orWhere('maturity_source', ArtworkMaturityService::SOURCE_LEGACY);
|
|
})
|
|
->where(function (Builder $state): void {
|
|
$state->whereNull('maturity_status')
|
|
->orWhere('maturity_status', ArtworkMaturityService::STATUS_CLEAR);
|
|
})
|
|
->where(function (Builder $state): void {
|
|
$state->whereNull('maturity_level')
|
|
->orWhere('maturity_level', ArtworkMaturityService::LEVEL_SAFE);
|
|
})
|
|
->where(function (Builder $state): void {
|
|
$state->whereNull('is_mature')
|
|
->orWhere('is_mature', false);
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
*/
|
|
private function nullableFloat(mixed $value): ?float
|
|
{
|
|
return is_numeric($value) ? (float) $value : null;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
*/
|
|
private function nullableString(mixed $value): ?string
|
|
{
|
|
$resolved = trim((string) $value);
|
|
|
|
return $resolved !== '' ? $resolved : null;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
*/
|
|
private function nullableLowerString(mixed $value): ?string
|
|
{
|
|
$resolved = $this->nullableString($value);
|
|
|
|
return $resolved !== null ? Str::lower($resolved) : null;
|
|
}
|
|
|
|
/**
|
|
* @param mixed $value
|
|
* @return list<string>|null
|
|
*/
|
|
private function normalizeLabels(mixed $value): ?array
|
|
{
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
|
|
$labels = array_values(array_filter(array_map(
|
|
static fn (mixed $label): string => Str::lower(trim((string) $label)),
|
|
$value,
|
|
)));
|
|
|
|
return $labels !== [] ? array_values(array_unique($labels)) : null;
|
|
}
|
|
} |