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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user