optimizations
This commit is contained in:
360
app/Services/CollectionHealthService.php
Normal file
360
app/Services/CollectionHealthService.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionQualitySnapshot;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class CollectionHealthService
|
||||
{
|
||||
public function evaluate(Collection $collection): array
|
||||
{
|
||||
$metadataCompleteness = $this->metadataCompletenessScore($collection);
|
||||
$freshness = $this->freshnessScore($collection);
|
||||
$engagement = $this->engagementScore($collection);
|
||||
$readiness = $this->editorialReadinessScore($collection, $metadataCompleteness, $freshness, $engagement);
|
||||
$flags = $this->flags($collection, $metadataCompleteness, $freshness, $engagement, $readiness);
|
||||
$healthState = $this->healthStateFromFlags($flags);
|
||||
$healthScore = $this->healthScore($metadataCompleteness, $freshness, $engagement, $readiness, $flags);
|
||||
$placementEligibility = $this->placementEligibility($collection, $healthState, $readiness);
|
||||
|
||||
return [
|
||||
'metadata_completeness_score' => $metadataCompleteness,
|
||||
'freshness_score' => $freshness,
|
||||
'engagement_score' => $engagement,
|
||||
'editorial_readiness_score' => $readiness,
|
||||
'health_score' => $healthScore,
|
||||
'health_state' => $healthState,
|
||||
'health_flags_json' => $flags,
|
||||
'readiness_state' => $this->readinessState($placementEligibility, $flags),
|
||||
'placement_eligibility' => $placementEligibility,
|
||||
'duplicate_cluster_key' => $this->duplicateClusterKey($collection),
|
||||
'trust_tier' => $this->trustTier($collection, $healthScore),
|
||||
'last_health_check_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function refresh(Collection $collection, ?User $actor = null, string $reason = 'refresh'): Collection
|
||||
{
|
||||
$payload = $this->evaluate($collection->fresh());
|
||||
$snapshotDate = now()->toDateString();
|
||||
|
||||
$collection->forceFill($payload)->save();
|
||||
|
||||
$snapshot = CollectionQualitySnapshot::query()
|
||||
->where('collection_id', $collection->id)
|
||||
->whereDate('snapshot_date', $snapshotDate)
|
||||
->first();
|
||||
|
||||
if ($snapshot) {
|
||||
$snapshot->forceFill([
|
||||
'quality_score' => $collection->quality_score,
|
||||
'health_score' => $payload['health_score'],
|
||||
'metadata_completeness_score' => $payload['metadata_completeness_score'],
|
||||
'freshness_score' => $payload['freshness_score'],
|
||||
'engagement_score' => $payload['engagement_score'],
|
||||
'readiness_score' => $payload['editorial_readiness_score'],
|
||||
'flags_json' => $payload['health_flags_json'],
|
||||
])->save();
|
||||
} else {
|
||||
CollectionQualitySnapshot::query()->create([
|
||||
'collection_id' => $collection->id,
|
||||
'snapshot_date' => $snapshotDate,
|
||||
'quality_score' => $collection->quality_score,
|
||||
'health_score' => $payload['health_score'],
|
||||
'metadata_completeness_score' => $payload['metadata_completeness_score'],
|
||||
'freshness_score' => $payload['freshness_score'],
|
||||
'engagement_score' => $payload['engagement_score'],
|
||||
'readiness_score' => $payload['editorial_readiness_score'],
|
||||
'flags_json' => $payload['health_flags_json'],
|
||||
]);
|
||||
}
|
||||
|
||||
$fresh = $collection->fresh();
|
||||
|
||||
app(CollectionHistoryService::class)->record(
|
||||
$fresh,
|
||||
$actor,
|
||||
'health_refreshed',
|
||||
sprintf('Collection health refreshed via %s.', $reason),
|
||||
null,
|
||||
[
|
||||
'health_state' => $fresh->health_state,
|
||||
'readiness_state' => $fresh->readiness_state,
|
||||
'placement_eligibility' => (bool) $fresh->placement_eligibility,
|
||||
'health_score' => (float) ($fresh->health_score ?? 0),
|
||||
'flags' => $fresh->health_flags_json,
|
||||
]
|
||||
);
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
public function summary(Collection $collection): array
|
||||
{
|
||||
return [
|
||||
'health_state' => $collection->health_state,
|
||||
'readiness_state' => $collection->readiness_state,
|
||||
'health_score' => $collection->health_score !== null ? (float) $collection->health_score : null,
|
||||
'metadata_completeness_score' => $collection->metadata_completeness_score !== null ? (float) $collection->metadata_completeness_score : null,
|
||||
'editorial_readiness_score' => $collection->editorial_readiness_score !== null ? (float) $collection->editorial_readiness_score : null,
|
||||
'freshness_score' => $collection->freshness_score !== null ? (float) $collection->freshness_score : null,
|
||||
'engagement_score' => $collection->engagement_score !== null ? (float) $collection->engagement_score : null,
|
||||
'placement_eligibility' => (bool) $collection->placement_eligibility,
|
||||
'flags' => is_array($collection->health_flags_json) ? $collection->health_flags_json : [],
|
||||
'duplicate_cluster_key' => $collection->duplicate_cluster_key,
|
||||
'canonical_collection_id' => $collection->canonical_collection_id ? (int) $collection->canonical_collection_id : null,
|
||||
'last_health_check_at' => optional($collection->last_health_check_at)?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function metadataCompletenessScore(Collection $collection): float
|
||||
{
|
||||
$score = 0.0;
|
||||
$score += filled($collection->title) ? 18.0 : 0.0;
|
||||
$score += filled($collection->summary) ? 18.0 : 0.0;
|
||||
$score += filled($collection->description) ? 14.0 : 0.0;
|
||||
$score += $this->hasStrongCover($collection) ? 18.0 : ($collection->resolvedCoverArtwork(false) ? 8.0 : 0.0);
|
||||
$score += (int) $collection->artworks_count >= 6 ? 16.0 : ((int) $collection->artworks_count >= 4 ? 10.0 : ((int) $collection->artworks_count >= 2 ? 5.0 : 0.0));
|
||||
$score += $collection->usesPremiumPresentation() ? 8.0 : 0.0;
|
||||
$score += filled($collection->campaign_key) || filled($collection->event_key) || filled($collection->season_key) ? 8.0 : 0.0;
|
||||
|
||||
return round(min(100.0, $score), 2);
|
||||
}
|
||||
|
||||
private function freshnessScore(Collection $collection): float
|
||||
{
|
||||
$reference = $collection->last_activity_at ?: $collection->updated_at ?: $collection->published_at;
|
||||
|
||||
if ($reference === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$days = max(0, now()->diffInDays($reference));
|
||||
|
||||
if ($days >= 45) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return round(max(0.0, 100.0 - (($days / 45) * 100.0)), 2);
|
||||
}
|
||||
|
||||
private function engagementScore(Collection $collection): float
|
||||
{
|
||||
$weighted = ((int) $collection->likes_count * 3.0)
|
||||
+ ((int) $collection->followers_count * 4.5)
|
||||
+ ((int) $collection->saves_count * 4.0)
|
||||
+ ((int) $collection->comments_count * 2.0)
|
||||
+ ((int) $collection->shares_count * 2.5)
|
||||
+ ((int) $collection->views_count * 0.08);
|
||||
|
||||
return round(min(100.0, $weighted), 2);
|
||||
}
|
||||
|
||||
private function editorialReadinessScore(Collection $collection, float $metadataCompleteness, float $freshness, float $engagement): float
|
||||
{
|
||||
$score = ($metadataCompleteness * 0.45) + ($freshness * 0.2) + ($engagement * 0.2);
|
||||
$score += $collection->moderation_status === Collection::MODERATION_ACTIVE ? 10.0 : -20.0;
|
||||
$score += $collection->visibility === Collection::VISIBILITY_PUBLIC ? 10.0 : -10.0;
|
||||
$score += in_array((string) $collection->workflow_state, [Collection::WORKFLOW_APPROVED, Collection::WORKFLOW_PROGRAMMED], true) ? 10.0 : 0.0;
|
||||
|
||||
return round(max(0.0, min(100.0, $score)), 2);
|
||||
}
|
||||
|
||||
private function flags(Collection $collection, float $metadataCompleteness, float $freshness, float $engagement, float $readiness): array
|
||||
{
|
||||
$flags = [];
|
||||
$artworksCount = (int) $collection->artworks_count;
|
||||
|
||||
if ($metadataCompleteness < 55) {
|
||||
$flags[] = Collection::HEALTH_NEEDS_METADATA;
|
||||
}
|
||||
|
||||
if ($artworksCount < 6) {
|
||||
$flags[] = Collection::HEALTH_LOW_CONTENT;
|
||||
}
|
||||
|
||||
if (! $this->hasStrongCover($collection)) {
|
||||
$flags[] = Collection::HEALTH_WEAK_COVER;
|
||||
}
|
||||
|
||||
if ($freshness <= 0.0 && $collection->isPubliclyAccessible()) {
|
||||
$flags[] = Collection::HEALTH_STALE;
|
||||
}
|
||||
|
||||
if ($engagement < 15 && $collection->isPubliclyAccessible() && $collection->published_at?->lt(now()->subDays(21))) {
|
||||
$flags[] = Collection::HEALTH_LOW_ENGAGEMENT;
|
||||
}
|
||||
|
||||
if ($collection->moderation_status !== Collection::MODERATION_ACTIVE || (string) $collection->workflow_state === Collection::WORKFLOW_IN_REVIEW) {
|
||||
$flags[] = Collection::HEALTH_NEEDS_REVIEW;
|
||||
}
|
||||
|
||||
if ($this->brokenItemsRatio($collection) > 0.25) {
|
||||
$flags[] = Collection::HEALTH_BROKEN_ITEMS;
|
||||
}
|
||||
|
||||
if ($this->hasDuplicateRisk($collection)) {
|
||||
$flags[] = Collection::HEALTH_DUPLICATE_RISK;
|
||||
}
|
||||
|
||||
if ($collection->canonical_collection_id !== null) {
|
||||
$flags[] = Collection::HEALTH_MERGE_CANDIDATE;
|
||||
}
|
||||
|
||||
if ($readiness < 45 && $collection->type === Collection::TYPE_EDITORIAL && $artworksCount < 6) {
|
||||
$flags[] = Collection::HEALTH_ATTRIBUTION_INCOMPLETE;
|
||||
}
|
||||
|
||||
return array_values(array_unique($flags));
|
||||
}
|
||||
|
||||
private function healthStateFromFlags(array $flags): string
|
||||
{
|
||||
foreach ([
|
||||
Collection::HEALTH_MERGE_CANDIDATE,
|
||||
Collection::HEALTH_DUPLICATE_RISK,
|
||||
Collection::HEALTH_NEEDS_REVIEW,
|
||||
Collection::HEALTH_BROKEN_ITEMS,
|
||||
Collection::HEALTH_LOW_CONTENT,
|
||||
Collection::HEALTH_WEAK_COVER,
|
||||
Collection::HEALTH_NEEDS_METADATA,
|
||||
Collection::HEALTH_STALE,
|
||||
Collection::HEALTH_LOW_ENGAGEMENT,
|
||||
Collection::HEALTH_ATTRIBUTION_INCOMPLETE,
|
||||
] as $flag) {
|
||||
if (in_array($flag, $flags, true)) {
|
||||
return $flag;
|
||||
}
|
||||
}
|
||||
|
||||
return Collection::HEALTH_HEALTHY;
|
||||
}
|
||||
|
||||
private function healthScore(float $metadataCompleteness, float $freshness, float $engagement, float $readiness, array $flags): float
|
||||
{
|
||||
$score = ($metadataCompleteness * 0.35) + ($freshness * 0.2) + ($engagement * 0.2) + ($readiness * 0.25);
|
||||
$score -= count($flags) * 6.5;
|
||||
|
||||
return round(max(0.0, min(100.0, $score)), 2);
|
||||
}
|
||||
|
||||
private function placementEligibility(Collection $collection, string $healthState, float $readiness): bool
|
||||
{
|
||||
if ($collection->visibility !== Collection::VISIBILITY_PUBLIC) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($collection->moderation_status !== Collection::MODERATION_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($collection->lifecycle_state, [Collection::LIFECYCLE_PUBLISHED, Collection::LIFECYCLE_FEATURED], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array($healthState, [Collection::HEALTH_BROKEN_ITEMS, Collection::HEALTH_MERGE_CANDIDATE], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($collection->workflow_state === Collection::WORKFLOW_IN_REVIEW) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $readiness >= 45.0;
|
||||
}
|
||||
|
||||
private function readinessState(bool $placementEligibility, array $flags): string
|
||||
{
|
||||
if (! $placementEligibility) {
|
||||
return Collection::READINESS_BLOCKED;
|
||||
}
|
||||
|
||||
if ($flags !== []) {
|
||||
return Collection::READINESS_NEEDS_WORK;
|
||||
}
|
||||
|
||||
return Collection::READINESS_READY;
|
||||
}
|
||||
|
||||
private function duplicateClusterKey(Collection $collection): ?string
|
||||
{
|
||||
$existing = trim((string) ($collection->duplicate_cluster_key ?? ''));
|
||||
|
||||
return $existing !== '' ? $existing : null;
|
||||
}
|
||||
|
||||
private function trustTier(Collection $collection, float $healthScore): string
|
||||
{
|
||||
if ($collection->type === Collection::TYPE_EDITORIAL) {
|
||||
return 'editorial';
|
||||
}
|
||||
|
||||
if ($healthScore >= 80) {
|
||||
return 'high';
|
||||
}
|
||||
|
||||
if ($healthScore >= 50) {
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
return 'limited';
|
||||
}
|
||||
|
||||
private function brokenItemsRatio(Collection $collection): float
|
||||
{
|
||||
if ($collection->isSmart() || (int) $collection->artworks_count === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$visibleCount = DB::table('collection_artwork as ca')
|
||||
->join('artworks as a', 'a.id', '=', 'ca.artwork_id')
|
||||
->where('ca.collection_id', $collection->id)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->whereNotNull('a.published_at')
|
||||
->where('a.published_at', '<=', now())
|
||||
->count();
|
||||
|
||||
return max(0.0, ((int) $collection->artworks_count - $visibleCount) / max(1, (int) $collection->artworks_count));
|
||||
}
|
||||
|
||||
private function hasDuplicateRisk(Collection $collection): bool
|
||||
{
|
||||
return Collection::query()
|
||||
->where('id', '!=', $collection->id)
|
||||
->where('user_id', $collection->user_id)
|
||||
->whereRaw('LOWER(title) = ?', [mb_strtolower(trim((string) $collection->title))])
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function hasStrongCover(Collection $collection): bool
|
||||
{
|
||||
if (! $collection->cover_artwork_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$cover = $collection->relationLoaded('coverArtwork')
|
||||
? $collection->coverArtwork
|
||||
: $collection->coverArtwork()->first();
|
||||
|
||||
if (! $cover instanceof Artwork) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($collection->isPubliclyAccessible() && ! $cover->published_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$width = (int) ($cover->width ?? 0);
|
||||
$height = (int) ($cover->height ?? 0);
|
||||
|
||||
return $width >= 320 && $height >= 220;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user