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:
MechaCat02
2026-07-05 17:42:05 +02:00
parent bb833c7e71
commit d6a109df2d
7 changed files with 277 additions and 5 deletions

View File

@@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
.route("/mangas", get(list).post(create))
.route("/mangas/:id", get(get_one).patch(update))
.route("/mangas/:id/similar", get(list_similar))
.route("/me/recommendations", get(list_recommendations))
.route("/mangas/:id/cover", put(put_cover).delete(delete_cover))
.route("/mangas/:id/tags", post(attach_tag))
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
@@ -188,6 +189,28 @@ async fn list_similar(
Ok(Json(json!({ "items": items })))
}
const RECOMMENDATIONS_LIMIT: i64 = 12;
#[derive(Debug, Deserialize)]
pub struct RecommendationParams {
#[serde(default)]
pub limit: Option<i64>,
}
/// `GET /api/v1/me/recommendations` — content-based "Recommended for you",
/// ranked by tag overlap with the signed-in user's likes/bookmarks (minus
/// dislikes). Returns `{ "items": [...] }` (a fixed top-N, like `/similar`);
/// empty when the user has no taste signals yet.
async fn list_recommendations(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<RecommendationParams>,
) -> AppResult<Json<serde_json::Value>> {
let limit = params.limit.unwrap_or(RECOMMENDATIONS_LIMIT).clamp(1, 50);
let items = repo::manga::list_recommendations(&state.db, user.id, limit).await?;
Ok(Json(json!({ "items": items })))
}
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
///
/// - `metadata` (required): JSON body matching `NewManga` — title, optional

View File

@@ -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