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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.107.0"
|
version = "0.108.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.107.0"
|
version = "0.108.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/mangas", get(list).post(create))
|
.route("/mangas", get(list).post(create))
|
||||||
.route("/mangas/:id", get(get_one).patch(update))
|
.route("/mangas/:id", get(get_one).patch(update))
|
||||||
.route("/mangas/:id/similar", get(list_similar))
|
.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/cover", put(put_cover).delete(delete_cover))
|
||||||
.route("/mangas/:id/tags", post(attach_tag))
|
.route("/mangas/:id/tags", post(attach_tag))
|
||||||
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
|
.route("/mangas/:id/tags/:tag_id", delete(detach_tag))
|
||||||
@@ -188,6 +189,28 @@ async fn list_similar(
|
|||||||
Ok(Json(json!({ "items": items })))
|
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:
|
/// `POST /api/v1/mangas` is multipart/form-data. Parts:
|
||||||
///
|
///
|
||||||
/// - `metadata` (required): JSON body matching `NewManga` — title, optional
|
/// - `metadata` (required): JSON body matching `NewManga` — title, optional
|
||||||
|
|||||||
@@ -307,6 +307,73 @@ pub async fn list_similar(
|
|||||||
cards_from_rows(pool, rows).await
|
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
|
/// Hydrate a batch of `Manga` rows into `MangaCard`s by attaching their
|
||||||
/// authors and genres in two batched round-trips. The input order is
|
/// authors and genres in two batched round-trips. The input order is
|
||||||
/// preserved (callers rely on this to keep list/ranking order), so we
|
/// preserved (callers rely on this to keep list/ranking order), so we
|
||||||
|
|||||||
182
backend/tests/api_recommendations.rs
Normal file
182
backend/tests/api_recommendations.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde_json::json;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
async fn attach_tag(app: &axum::Router, cookie: &str, manga_id: Uuid, name: &str) {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{manga_id}/tags"),
|
||||||
|
json!({ "name": name }),
|
||||||
|
cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_success(), "attach_tag: {}", resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_reaction(app: &axum::Router, cookie: &str, manga_id: Uuid, reaction: &str) {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::put_json_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{manga_id}/reaction"),
|
||||||
|
json!({ "reaction": reaction }),
|
||||||
|
cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bookmark(app: &axum::Router, cookie: &str, manga_id: Uuid) {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::post_json_with_cookie(
|
||||||
|
"/api/v1/bookmarks",
|
||||||
|
json!({ "manga_id": manga_id.to_string() }),
|
||||||
|
cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_read(app: &axum::Router, cookie: &str, manga_id: Uuid) {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::put_json_with_cookie(
|
||||||
|
"/api/v1/me/read-progress",
|
||||||
|
json!({ "manga_id": manga_id.to_string(), "page": 1 }),
|
||||||
|
cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recommended manga titles, in ranked order.
|
||||||
|
async fn recommend(app: &axum::Router, cookie: &str) -> Vec<String> {
|
||||||
|
let resp = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get_with_cookie("/api/v1/me/recommendations", cookie))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
let body = common::body_json(resp).await;
|
||||||
|
body["items"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|m| m["title"].as_str().unwrap().to_string())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn likes_drive_recommendations(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
|
||||||
|
let similar = common::seed_manga_via_api(&h.app, &cookie, "Similar").await;
|
||||||
|
let unrelated = common::seed_manga_via_api(&h.app, &cookie, "Unrelated").await;
|
||||||
|
attach_tag(&h.app, &cookie, liked, "action").await;
|
||||||
|
attach_tag(&h.app, &cookie, similar, "action").await;
|
||||||
|
attach_tag(&h.app, &cookie, unrelated, "sports").await;
|
||||||
|
|
||||||
|
set_reaction(&h.app, &cookie, liked, "like").await;
|
||||||
|
|
||||||
|
let recs = recommend(&h.app, &cookie).await;
|
||||||
|
assert!(recs.contains(&"Similar".to_string()), "recs: {recs:?}");
|
||||||
|
assert!(!recs.contains(&"Unrelated".to_string()), "recs: {recs:?}");
|
||||||
|
// The liked manga itself is not recommended back.
|
||||||
|
assert!(!recs.contains(&"Liked".to_string()), "recs: {recs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn dislike_downranks_shared_tags(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
|
||||||
|
let disliked = common::seed_manga_via_api(&h.app, &cookie, "Disliked").await;
|
||||||
|
let good = common::seed_manga_via_api(&h.app, &cookie, "Good").await;
|
||||||
|
let bad = common::seed_manga_via_api(&h.app, &cookie, "Bad").await;
|
||||||
|
attach_tag(&h.app, &cookie, liked, "action").await;
|
||||||
|
attach_tag(&h.app, &cookie, good, "action").await; // shares the liked tag
|
||||||
|
attach_tag(&h.app, &cookie, disliked, "gore").await;
|
||||||
|
attach_tag(&h.app, &cookie, bad, "gore").await; // shares the disliked tag
|
||||||
|
|
||||||
|
set_reaction(&h.app, &cookie, liked, "like").await;
|
||||||
|
set_reaction(&h.app, &cookie, disliked, "dislike").await;
|
||||||
|
|
||||||
|
let recs = recommend(&h.app, &cookie).await;
|
||||||
|
assert!(recs.contains(&"Good".to_string()), "recs: {recs:?}");
|
||||||
|
// Net-negative (disliked-tag) candidate is dropped.
|
||||||
|
assert!(!recs.contains(&"Bad".to_string()), "recs: {recs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn bookmark_counts_as_half_a_like(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
|
||||||
|
let booked = common::seed_manga_via_api(&h.app, &cookie, "Booked").await;
|
||||||
|
let from_like = common::seed_manga_via_api(&h.app, &cookie, "FromLike").await;
|
||||||
|
let from_bookmark = common::seed_manga_via_api(&h.app, &cookie, "FromBookmark").await;
|
||||||
|
attach_tag(&h.app, &cookie, liked, "tliked").await;
|
||||||
|
attach_tag(&h.app, &cookie, from_like, "tliked").await; // affinity 1.0
|
||||||
|
attach_tag(&h.app, &cookie, booked, "tbooked").await;
|
||||||
|
attach_tag(&h.app, &cookie, from_bookmark, "tbooked").await; // affinity 0.5
|
||||||
|
|
||||||
|
set_reaction(&h.app, &cookie, liked, "like").await;
|
||||||
|
bookmark(&h.app, &cookie, booked).await;
|
||||||
|
|
||||||
|
let recs = recommend(&h.app, &cookie).await;
|
||||||
|
// Both recommended, but the like-derived one outranks the bookmark-derived.
|
||||||
|
let i_like = recs.iter().position(|t| t == "FromLike");
|
||||||
|
let i_book = recs.iter().position(|t| t == "FromBookmark");
|
||||||
|
assert!(i_like.is_some() && i_book.is_some(), "recs: {recs:?}");
|
||||||
|
assert!(i_like < i_book, "like should outrank bookmark; recs: {recs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn excludes_already_seen(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let liked = common::seed_manga_via_api(&h.app, &cookie, "Liked").await;
|
||||||
|
let fresh = common::seed_manga_via_api(&h.app, &cookie, "Fresh").await;
|
||||||
|
let already_read = common::seed_manga_via_api(&h.app, &cookie, "AlreadyRead").await;
|
||||||
|
for m in [liked, fresh, already_read] {
|
||||||
|
attach_tag(&h.app, &cookie, m, "action").await;
|
||||||
|
}
|
||||||
|
set_reaction(&h.app, &cookie, liked, "like").await;
|
||||||
|
mark_read(&h.app, &cookie, already_read).await;
|
||||||
|
|
||||||
|
let recs = recommend(&h.app, &cookie).await;
|
||||||
|
assert!(recs.contains(&"Fresh".to_string()), "recs: {recs:?}");
|
||||||
|
assert!(!recs.contains(&"AlreadyRead".to_string()), "recs: {recs:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn empty_without_signals(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let m = common::seed_manga_via_api(&h.app, &cookie, "Whatever").await;
|
||||||
|
attach_tag(&h.app, &cookie, m, "action").await;
|
||||||
|
|
||||||
|
assert!(recommend(&h.app, &cookie).await.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn requires_authentication(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.clone()
|
||||||
|
.oneshot(common::get("/api/v1/me/recommendations"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.107.0",
|
"version": "0.108.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.107.0",
|
"version": "0.108.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lucide/svelte": "^1.16.0",
|
"@lucide/svelte": "^1.16.0",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.107.0",
|
"version": "0.108.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user