Files
SkinbaseNova/app/Services/Maturity/ArtworkMaturityAuditService.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;
}
}