validate([ 'artwork_id' => ['required', 'integer', 'exists:artworks,id'], 'algo_version' => ['nullable', 'string', 'max:64'], 'source' => ['nullable', 'string', 'max:64'], 'meta' => ['nullable', 'array'], ]); $signal = UserNegativeSignal::query()->updateOrCreate( [ 'user_id' => (int) $request->user()->id, 'signal_type' => 'hide_artwork', 'artwork_id' => (int) $payload['artwork_id'], ], [ 'tag_id' => null, 'algo_version' => $payload['algo_version'] ?? null, 'source' => $payload['source'] ?? 'api', 'meta' => (array) ($payload['meta'] ?? []), ] ); $this->recordFeedbackEvent( userId: (int) $request->user()->id, artworkId: (int) $payload['artwork_id'], eventType: 'hide_artwork', algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null, meta: (array) ($payload['meta'] ?? []) ); return response()->json([ 'stored' => true, 'signal_id' => (int) $signal->id, 'signal_type' => 'hide_artwork', ], Response::HTTP_ACCEPTED); } public function dislikeTag(Request $request): JsonResponse { $payload = $request->validate([ 'tag_id' => ['nullable', 'integer', 'exists:tags,id'], 'tag_slug' => ['nullable', 'string', 'max:191'], 'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'], 'algo_version' => ['nullable', 'string', 'max:64'], 'source' => ['nullable', 'string', 'max:64'], 'meta' => ['nullable', 'array'], ]); $tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null; if ($tagId === null && ! empty($payload['tag_slug'])) { $tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id'); } abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.'); $signal = UserNegativeSignal::query()->updateOrCreate( [ 'user_id' => (int) $request->user()->id, 'signal_type' => 'dislike_tag', 'tag_id' => $tagId, ], [ 'artwork_id' => null, 'algo_version' => $payload['algo_version'] ?? null, 'source' => $payload['source'] ?? 'api', 'meta' => (array) ($payload['meta'] ?? []), ] ); $this->recordFeedbackEvent( userId: (int) $request->user()->id, artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)), eventType: 'dislike_tag', algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null, meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId]) ); return response()->json([ 'stored' => true, 'signal_id' => (int) $signal->id, 'signal_type' => 'dislike_tag', 'tag_id' => $tagId, ], Response::HTTP_ACCEPTED); } public function unhideArtwork(Request $request): JsonResponse { $payload = $request->validate([ 'artwork_id' => ['required', 'integer', 'exists:artworks,id'], 'algo_version' => ['nullable', 'string', 'max:64'], 'meta' => ['nullable', 'array'], ]); $deleted = UserNegativeSignal::query() ->where('user_id', (int) $request->user()->id) ->where('signal_type', 'hide_artwork') ->where('artwork_id', (int) $payload['artwork_id']) ->delete(); if ($deleted > 0) { $this->recordFeedbackEvent( userId: (int) $request->user()->id, artworkId: (int) $payload['artwork_id'], eventType: 'unhide_artwork', algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null, meta: (array) ($payload['meta'] ?? []) ); } return response()->json([ 'revoked' => $deleted > 0, 'signal_type' => 'hide_artwork', 'artwork_id' => (int) $payload['artwork_id'], ], Response::HTTP_OK); } public function undislikeTag(Request $request): JsonResponse { $payload = $request->validate([ 'tag_id' => ['nullable', 'integer', 'exists:tags,id'], 'tag_slug' => ['nullable', 'string', 'max:191'], 'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'], 'algo_version' => ['nullable', 'string', 'max:64'], 'meta' => ['nullable', 'array'], ]); $tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null; if ($tagId === null && ! empty($payload['tag_slug'])) { $tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id'); } abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.'); $deleted = UserNegativeSignal::query() ->where('user_id', (int) $request->user()->id) ->where('signal_type', 'dislike_tag') ->where('tag_id', $tagId) ->delete(); if ($deleted > 0) { $this->recordFeedbackEvent( userId: (int) $request->user()->id, artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)), eventType: 'undo_dislike_tag', algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null, meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId]) ); } return response()->json([ 'revoked' => $deleted > 0, 'signal_type' => 'dislike_tag', 'tag_id' => $tagId, ], Response::HTTP_OK); } /** * @param array $meta */ private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void { if ($artworkId <= 0 || ! Schema::hasTable('user_discovery_events')) { return; } $categoryId = DB::table('artwork_category') ->where('artwork_id', $artworkId) ->orderBy('category_id') ->value('category_id'); DB::table('user_discovery_events')->insert([ 'event_id' => (string) Str::uuid(), 'user_id' => $userId, 'artwork_id' => $artworkId, 'category_id' => $categoryId !== null ? (int) $categoryId : null, 'event_type' => $eventType, 'event_version' => (string) config('discovery.event_version', 'event-v1'), 'algo_version' => (string) ($algoVersion ?: config('discovery.v2.algo_version', config('discovery.algo_version', 'clip-cosine-v1'))), 'weight' => 0.0, 'event_date' => now()->toDateString(), 'occurred_at' => now()->toDateTimeString(), 'meta' => json_encode($meta, JSON_THROW_ON_ERROR), 'created_at' => now(), 'updated_at' => now(), ]); } }