Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal file
148
app/Jobs/RecBuildItemPairsFromFavouritesJob.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Build item-item co-occurrence pairs from user favourites.
|
||||
*
|
||||
* Spec §7.1 — runs hourly or every few hours.
|
||||
* For each user: take last N favourites, create pairs, increment weights.
|
||||
*
|
||||
* Safety: limits per-user pairs to avoid O(n²) explosion.
|
||||
*/
|
||||
final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $userBatchSize = 500,
|
||||
) {
|
||||
$queue = (string) config('recommendations.queue', 'default');
|
||||
if ($queue !== '') {
|
||||
$this->onQueue($queue);
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$favCap = (int) config('recommendations.similarity.user_favourites_cap', 50);
|
||||
|
||||
// ── Pre-compute per-artwork total favourite counts for cosine normalization ──
|
||||
$this->artworkLikeCounts = DB::table('artwork_favourites')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
|
||||
->groupBy('artwork_id')
|
||||
->pluck('cnt', 'artwork_id')
|
||||
->all();
|
||||
|
||||
// ── Accumulate co-occurrence counts across all users ──
|
||||
$coOccurrenceCounts = [];
|
||||
|
||||
DB::table('artwork_favourites')
|
||||
->select('user_id')
|
||||
->groupBy('user_id')
|
||||
->orderBy('user_id')
|
||||
->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) {
|
||||
foreach ($userRows as $row) {
|
||||
$pairs = $this->pairsForUser((int) $row->user_id, $favCap);
|
||||
foreach ($pairs as $pair) {
|
||||
$key = $pair[0] . ':' . $pair[1];
|
||||
$coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Normalize to cosine-like scores and flush ──
|
||||
$normalized = [];
|
||||
foreach ($coOccurrenceCounts as $key => $count) {
|
||||
[$a, $b] = explode(':', $key);
|
||||
$likesA = $this->artworkLikeCounts[(int) $a] ?? 1;
|
||||
$likesB = $this->artworkLikeCounts[(int) $b] ?? 1;
|
||||
$normalized[$key] = $count / sqrt($likesA * $likesB);
|
||||
}
|
||||
|
||||
$this->flushPairs($normalized);
|
||||
}
|
||||
|
||||
/** @var array<int, int> artwork_id => total favourite count */
|
||||
private array $artworkLikeCounts = [];
|
||||
|
||||
/**
|
||||
* Collect pairs from a single user's last N favourites.
|
||||
*
|
||||
* @return list<array{0: int, 1: int}>
|
||||
*/
|
||||
public function pairsForUser(int $userId, int $cap): array
|
||||
{
|
||||
$artworkIds = DB::table('artwork_favourites')
|
||||
->where('user_id', $userId)
|
||||
->orderByDesc('created_at')
|
||||
->limit($cap)
|
||||
->pluck('artwork_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
|
||||
$count = count($artworkIds);
|
||||
if ($count < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pairs = [];
|
||||
// Cap max pairs per user to avoid explosion: C(50,2) = 1225 worst case = acceptable
|
||||
for ($i = 0; $i < $count - 1; $i++) {
|
||||
for ($j = $i + 1; $j < $count; $j++) {
|
||||
$a = min($artworkIds[$i], $artworkIds[$j]);
|
||||
$b = max($artworkIds[$i], $artworkIds[$j]);
|
||||
$pairs[] = [$a, $b];
|
||||
}
|
||||
}
|
||||
|
||||
return $pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert normalized pair weights into rec_item_pairs.
|
||||
*
|
||||
* Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite).
|
||||
*
|
||||
* @param array<string, float> $upserts key = "a:b", value = cosine-normalized weight
|
||||
*/
|
||||
private function flushPairs(array $upserts): void
|
||||
{
|
||||
if ($upserts === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
|
||||
foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) {
|
||||
$rows = [];
|
||||
foreach ($chunk as $key => $weight) {
|
||||
[$a, $b] = explode(':', $key);
|
||||
$rows[] = [
|
||||
'a_artwork_id' => (int) $a,
|
||||
'b_artwork_id' => (int) $b,
|
||||
'weight' => $weight,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
DB::table('rec_item_pairs')->upsert(
|
||||
$rows,
|
||||
['a_artwork_id', 'b_artwork_id'],
|
||||
['weight', 'updated_at'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user