feat(recommendations): content-based "Recommended for you" endpoint
Add GET /me/recommendations: rank mangas by weighted tag overlap with the user's taste — explicit like +1.0, bookmark +0.5, dislike −1.0 (reaction overrides bookmark). Per-tag affinities are summed, candidates scored by their tags' affinity normalized by tag count (anti-tag-stuffing, à la list_similar), with already-reacted/bookmarked/read mangas excluded and net-negative candidates dropped (dislike down-ranks, not browse-hides). Reuses manga_cols/cards_from_rows. Integration tests cover like-driven recs, dislike down-rank, bookmark half-weight ordering, seen-exclusion, empty cold-start, and auth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -307,6 +307,73 @@ pub async fn list_similar(
|
||||
cards_from_rows(pool, rows).await
|
||||
}
|
||||
|
||||
/// Content-based "Recommended for you": rank mangas by weighted tag overlap
|
||||
/// with the user's taste. Signals: explicit like = +1.0, bookmark = +0.5,
|
||||
/// dislike = -1.0 (a reaction overrides a bookmark on the same manga). Per
|
||||
/// tag we sum those weights into an affinity, then score each candidate by
|
||||
/// the sum of its tags' affinities, normalized by the candidate's tag count
|
||||
/// (same anti-tag-stuffing rationale as `list_similar`). Candidates the user
|
||||
/// already reacted to, bookmarked, or read are excluded; net-negative
|
||||
/// candidates (dominated by disliked-tag affinity) are dropped, so a dislike
|
||||
/// down-ranks rather than the manga being hidden from normal browse. No
|
||||
/// signals → empty. Reuses `cards_from_rows` for author/genre hydration.
|
||||
pub async fn list_recommendations(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
) -> AppResult<Vec<MangaCard>> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
WITH signals AS (
|
||||
SELECT s.manga_id,
|
||||
CASE WHEN r.reaction = 'dislike' THEN -1.0
|
||||
WHEN r.reaction = 'like' THEN 1.0
|
||||
ELSE 0.5 END AS weight
|
||||
FROM (
|
||||
SELECT manga_id FROM manga_reactions WHERE user_id = $1
|
||||
UNION
|
||||
SELECT manga_id FROM bookmarks WHERE user_id = $1
|
||||
) s
|
||||
LEFT JOIN manga_reactions r
|
||||
ON r.user_id = $1 AND r.manga_id = s.manga_id
|
||||
),
|
||||
tag_affinity AS (
|
||||
SELECT mt.tag_id, SUM(sig.weight) AS affinity
|
||||
FROM signals sig
|
||||
JOIN manga_tags mt ON mt.manga_id = sig.manga_id
|
||||
GROUP BY mt.tag_id
|
||||
)
|
||||
SELECT {cols}
|
||||
FROM manga_tags cand
|
||||
JOIN tag_affinity ta ON ta.tag_id = cand.tag_id
|
||||
JOIN mangas m ON m.id = cand.manga_id
|
||||
WHERE cand.manga_id NOT IN (SELECT manga_id FROM signals)
|
||||
AND cand.manga_id NOT IN (
|
||||
SELECT manga_id FROM read_progress WHERE user_id = $1
|
||||
)
|
||||
GROUP BY m.id
|
||||
HAVING SUM(ta.affinity) > 0
|
||||
ORDER BY
|
||||
SUM(ta.affinity)
|
||||
/ (SELECT count(*) FROM manga_tags WHERE manga_id = m.id) DESC,
|
||||
SUM(ta.affinity) DESC,
|
||||
m.updated_at DESC,
|
||||
lower(m.title) ASC,
|
||||
m.id
|
||||
LIMIT $2
|
||||
"#,
|
||||
cols = manga_cols("m"),
|
||||
);
|
||||
|
||||
let rows = sqlx::query_as::<_, Manga>(&sql)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
cards_from_rows(pool, rows).await
|
||||
}
|
||||
|
||||
/// Hydrate a batch of `Manga` rows into `MangaCard`s by attaching their
|
||||
/// authors and genres in two batched round-trips. The input order is
|
||||
/// preserved (callers rely on this to keep list/ranking order), so we
|
||||
|
||||
Reference in New Issue
Block a user