RecommendationRankingEngine.java

package org.darkroomlibrary.service.support;

import org.darkroomlibrary.domain.recommendation.RecommendationBookProfile;
import org.darkroomlibrary.domain.recommendation.RecommendationFavoriteLink;
import org.darkroomlibrary.domain.recommendation.RecommendationUserSignal;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@Component
public class RecommendationRankingEngine {

    public static final String ALGORITHM_VERSION = "bookmark-hybrid-v1";
    private static final int PERSONALIZATION_THRESHOLD = 3;
    private static final double EPSILON = 0.000001d;

    public RecommendationPlan rank(Integer userId,
                                   List<RecommendationBookProfile> bookProfiles,
                                   List<RecommendationUserSignal> userSignals,
                                   List<RecommendationFavoriteLink> favoriteLinks,
                                   boolean enabled,
                                   int limit,
                                   LocalDateTime now) {
        List<RecommendationBookProfile> books = bookProfiles == null ? List.of() : bookProfiles;
        List<RecommendationUserSignal> signals = userSignals == null ? List.of() : userSignals;
        List<RecommendationFavoriteLink> links = favoriteLinks == null ? List.of() : favoriteLinks;
        Map<Integer, RecommendationBookProfile> profilesById = books.stream()
                .collect(Collectors.toMap(RecommendationBookProfile::getId, book -> book));

        Set<Integer> favoriteBookIds = signals.stream()
                .filter(signal -> value(signal.getFavoriteCount()) > 0)
                .map(RecommendationUserSignal::getBookId)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        Set<Integer> excludedBookIds = signals.stream()
                .filter(signal -> value(signal.getFavoriteCount()) > 0
                        || value(signal.getActiveBorrowCount()) > 0
                        || (value(signal.getReviewCount()) > 0 && decimal(signal.getAverageRating()) <= 2d))
                .map(RecommendationUserSignal::getBookId)
                .collect(Collectors.toSet());

        boolean personalized = enabled && favoriteBookIds.size() >= PERSONALIZATION_THRESHOLD;
        Map<String, Double> userVector = personalized
                ? buildUserVector(signals, profilesById, now)
                : Map.of();
        CollaborativeScores collaborative = personalized
                ? collaborativeScores(favoriteBookIds, links, profilesById.keySet())
                : CollaborativeScores.empty();

        List<RecommendationBookProfile> candidates = books.stream()
                .filter(book -> !excludedBookIds.contains(book.getId()))
                .toList();
        Map<Integer, Double> qualityScores = qualityScores(candidates, now);
        Map<Integer, RecommendationUserSignal> signalsByBook = signals.stream()
                .collect(Collectors.toMap(RecommendationUserSignal::getBookId, signal -> signal));

        boolean hasCollaborativeData = collaborative.scores().values().stream()
                .anyMatch(score -> score > EPSILON);
        String mode = !personalized ? "PUBLIC" : hasCollaborativeData ? "HYBRID" : "CONTENT";
        List<ScoredCandidate> scored = new ArrayList<>();
        for (RecommendationBookProfile candidate : candidates) {
            double content = personalized
                    ? cosine(userVector, featureVector(candidate))
                    : 0d;
            double collaborativeScore = collaborative.scores().getOrDefault(candidate.getId(), 0d);
            double quality = qualityScores.getOrDefault(candidate.getId(), 0d);
            double exploration = explorationScore(userId, candidate.getId(), now.toLocalDate());
            double total;
            if (!personalized) {
                total = quality * 0.75d + exploration * 0.25d;
            } else if (hasCollaborativeData) {
                total = content * 0.55d + collaborativeScore * 0.25d
                        + quality * 0.15d + exploration * 0.05d;
            } else {
                total = content * 0.70d + quality * 0.20d + exploration * 0.10d;
            }
            RecommendationUserSignal previous = signalsByBook.get(candidate.getId());
            if (previous != null && (value(previous.getBorrowCount()) > 0
                    || value(previous.getReviewCount()) > 0)) {
                total *= 0.68d;
            }
            String sourceType = sourceType(personalized, hasCollaborativeData, content,
                    collaborativeScore, candidate, now);
            String reason = reason(sourceType, candidate, favoriteBookIds, profilesById,
                    collaborative.sourceBookByCandidate().get(candidate.getId()), quality);
            scored.add(new ScoredCandidate(candidate, total, content, collaborativeScore,
                    quality, exploration, sourceType, reason));
        }
        scored.sort(Comparator.comparingDouble(ScoredCandidate::total).reversed()
                .thenComparing(candidate -> candidate.book().getId()));

        return new RecommendationPlan(mode, personalized, favoriteBookIds.size(),
                diversify(scored, Math.max(1, Math.min(limit, 12))));
    }

    private Map<String, Double> buildUserVector(List<RecommendationUserSignal> signals,
                                                Map<Integer, RecommendationBookProfile> profilesById,
                                                LocalDateTime now) {
        Map<String, Double> vector = new HashMap<>();
        for (RecommendationUserSignal signal : signals) {
            RecommendationBookProfile book = profilesById.get(signal.getBookId());
            if (book == null) continue;
            double signalWeight = value(signal.getFavoriteCount()) * 6d
                    + Math.min(3, value(signal.getBorrowCount())) * 1.5d;
            if (value(signal.getReviewCount()) > 0 && decimal(signal.getAverageRating()) >= 4d) {
                signalWeight += (decimal(signal.getAverageRating()) - 3d) * 2d;
            }
            if (signalWeight <= 0d) continue;
            long days = signal.getLatestInteractionTime() == null
                    ? 0L
                    : Math.max(0L, ChronoUnit.DAYS.between(signal.getLatestInteractionTime(), now));
            double decay = Math.max(0.25d, Math.pow(0.5d, days / 180d));
            double weightedSignal = signalWeight * decay;
            featureVector(book).forEach((feature, weight) ->
                    vector.merge(feature, weight * weightedSignal, Double::sum));
        }
        return vector;
    }

    private Map<String, Double> featureVector(RecommendationBookProfile book) {
        Map<String, Double> vector = new LinkedHashMap<>();
        addFeature(vector, "category:", book.getCategory(), 3d);
        addFeature(vector, "author:", book.getAuthor(), 2.2d);
        addFeature(vector, "publisher:", book.getPublisher(), 0.6d);
        for (String token : textTokens(safe(book.getName()) + " " + safe(book.getDescription()))) {
            vector.put("text:" + token, 0.28d);
        }
        return vector;
    }

    private void addFeature(Map<String, Double> vector, String prefix, String value, double weight) {
        String normalized = normalize(value);
        if (!normalized.isEmpty()) vector.put(prefix + normalized, weight);
    }

    private Set<String> textTokens(String text) {
        String normalized = normalize(text);
        Set<String> tokens = new LinkedHashSet<>();
        StringBuilder hanRun = new StringBuilder();
        StringBuilder latinRun = new StringBuilder();
        for (int offset = 0; offset < normalized.length();) {
            int codePoint = normalized.codePointAt(offset);
            offset += Character.charCount(codePoint);
            if (Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN) {
                flushLatin(tokens, latinRun);
                hanRun.appendCodePoint(codePoint);
                continue;
            }
            flushHan(tokens, hanRun);
            if (Character.isLetterOrDigit(codePoint)) {
                latinRun.appendCodePoint(codePoint);
            } else {
                flushLatin(tokens, latinRun);
            }
        }
        flushHan(tokens, hanRun);
        flushLatin(tokens, latinRun);
        return tokens.stream().limit(80).collect(Collectors.toCollection(LinkedHashSet::new));
    }

    private void flushHan(Set<String> tokens, StringBuilder run) {
        int[] codePoints = run.toString().codePoints().toArray();
        if (codePoints.length >= 2) {
            for (int index = 0; index < codePoints.length - 1; index++) {
                tokens.add(new String(codePoints, index, 2));
            }
        }
        run.setLength(0);
    }

    private void flushLatin(Set<String> tokens, StringBuilder run) {
        if (run.length() >= 3) tokens.add(run.toString());
        run.setLength(0);
    }

    private CollaborativeScores collaborativeScores(Set<Integer> favoriteBookIds,
                                                     List<RecommendationFavoriteLink> links,
                                                     Set<Integer> activeBookIds) {
        Map<Integer, Set<Integer>> usersByBook = new HashMap<>();
        for (RecommendationFavoriteLink link : links) {
            if (activeBookIds.contains(link.getBookId())) {
                usersByBook.computeIfAbsent(link.getBookId(), ignored -> new HashSet<>())
                        .add(link.getUserId());
            }
        }
        Map<Integer, Double> raw = new HashMap<>();
        Map<Integer, Integer> sourceByCandidate = new HashMap<>();
        for (Integer candidateId : activeBookIds) {
            if (favoriteBookIds.contains(candidateId)) continue;
            Set<Integer> candidateUsers = usersByBook.getOrDefault(candidateId, Set.of());
            double best = 0d;
            Integer bestSource = null;
            for (Integer sourceId : favoriteBookIds) {
                Set<Integer> sourceUsers = usersByBook.getOrDefault(sourceId, Set.of());
                int coCount = intersectionSize(sourceUsers, candidateUsers);
                if (coCount < 2) continue;
                double cosine = coCount / Math.sqrt((double) sourceUsers.size() * candidateUsers.size());
                double shrunk = cosine * coCount / (coCount + 2d);
                if (shrunk > best) {
                    best = shrunk;
                    bestSource = sourceId;
                }
            }
            if (bestSource != null) {
                raw.put(candidateId, best);
                sourceByCandidate.put(candidateId, bestSource);
            }
        }
        double max = raw.values().stream().mapToDouble(Double::doubleValue).max().orElse(0d);
        if (max > EPSILON) raw.replaceAll((bookId, score) -> score / max);
        return new CollaborativeScores(raw, sourceByCandidate);
    }

    private int intersectionSize(Set<Integer> left, Set<Integer> right) {
        if (left.isEmpty() || right.isEmpty()) return 0;
        Set<Integer> smaller = left.size() <= right.size() ? left : right;
        Set<Integer> larger = left.size() <= right.size() ? right : left;
        return (int) smaller.stream().filter(larger::contains).count();
    }

    private Map<Integer, Double> qualityScores(List<RecommendationBookProfile> books,
                                               LocalDateTime now) {
        Map<Integer, Double> rawPopularity = new HashMap<>();
        double maxPopularity = 0d;
        for (RecommendationBookProfile book : books) {
            double popularity = Math.log1p(value(book.getFavoriteCount()) * 3d
                    + value(book.getBorrowCount()) * 2d + value(book.getReviewCount()));
            rawPopularity.put(book.getId(), popularity);
            maxPopularity = Math.max(maxPopularity, popularity);
        }
        Map<Integer, Double> quality = new HashMap<>();
        for (RecommendationBookProfile book : books) {
            double popularity = maxPopularity <= EPSILON
                    ? 0d : rawPopularity.get(book.getId()) / maxPopularity;
            double rating = value(book.getReviewCount()) == 0
                    ? 0.5d : Math.min(1d, decimal(book.getAverageRating()) / 5d);
            long ageDays = book.getCreateTime() == null
                    ? 365L : Math.max(0L, ChronoUnit.DAYS.between(book.getCreateTime(), now));
            double freshness = Math.exp(-ageDays / 365d);
            double availability = value(book.getTotalCount()) <= 0
                    ? 0d : Math.min(1d, value(book.getAvailableCount()) / (double) value(book.getTotalCount()));
            quality.put(book.getId(), popularity * 0.5d + rating * 0.2d
                    + freshness * 0.2d + availability * 0.1d);
        }
        return quality;
    }

    private double cosine(Map<String, Double> left, Map<String, Double> right) {
        if (left.isEmpty() || right.isEmpty()) return 0d;
        double dot = 0d;
        double leftNorm = 0d;
        double rightNorm = 0d;
        for (double value : left.values()) leftNorm += value * value;
        for (Map.Entry<String, Double> entry : right.entrySet()) {
            double rightValue = entry.getValue();
            rightNorm += rightValue * rightValue;
            dot += left.getOrDefault(entry.getKey(), 0d) * rightValue;
        }
        if (leftNorm <= EPSILON || rightNorm <= EPSILON) return 0d;
        return dot / Math.sqrt(leftNorm * rightNorm);
    }

    private double explorationScore(Integer userId, Integer bookId, LocalDate day) {
        long hash = 17L;
        hash = hash * 31L + (userId == null ? 0 : userId);
        hash = hash * 31L + (bookId == null ? 0 : bookId);
        hash = hash * 31L + day.toEpochDay();
        return Math.floorMod(hash, 1000L) / 999d;
    }

    private String sourceType(boolean personalized,
                              boolean hasCollaborativeData,
                              double content,
                              double collaborative,
                              RecommendationBookProfile book,
                              LocalDateTime now) {
        if (!personalized) {
            long ageDays = book.getCreateTime() == null
                    ? Long.MAX_VALUE : ChronoUnit.DAYS.between(book.getCreateTime(), now);
            return ageDays <= 45 ? "NEW" : "PUBLIC";
        }
        if (hasCollaborativeData && collaborative * 0.25d > content * 0.55d) {
            return "COLLABORATIVE";
        }
        return content > EPSILON ? "CONTENT" : "DISCOVERY";
    }

    private String reason(String sourceType,
                          RecommendationBookProfile candidate,
                          Set<Integer> favoriteBookIds,
                          Map<Integer, RecommendationBookProfile> profilesById,
                          Integer collaborativeSourceId,
                          double quality) {
        if ("COLLABORATIVE".equals(sourceType) && collaborativeSourceId != null) {
            RecommendationBookProfile source = profilesById.get(collaborativeSourceId);
            if (source != null) return "收藏过《" + source.getName() + "》的读者,也常留下这本。";
        }
        if ("CONTENT".equals(sourceType)) {
            for (Integer favoriteId : favoriteBookIds) {
                RecommendationBookProfile source = profilesById.get(favoriteId);
                if (source != null && same(source.getAuthor(), candidate.getAuthor())) {
                    return "你曾留下《" + source.getName() + "》,这本书也出自" + candidate.getAuthor() + "。";
                }
            }
            for (Integer favoriteId : favoriteBookIds) {
                RecommendationBookProfile source = profilesById.get(favoriteId);
                if (source != null && same(source.getCategory(), candidate.getCategory())) {
                    return "沿着你收藏的「" + candidate.getCategory() + "」书签,灯下又出现了它。";
                }
            }
            return "它与你留下的几本书共享一些安静的线索。";
        }
        if ("NEW".equals(sourceType)) return "新近入藏,尚有许多页没有被谈起。";
        if (quality >= 0.72d) return "最近有人借阅、收藏或谈起这本书。";
        return "它与已有书签稍远,留作一次偶然相遇。";
    }

    private List<RankedRecommendation> diversify(List<ScoredCandidate> sorted, int limit) {
        List<ScoredCandidate> selected = new ArrayList<>();
        Set<Integer> selectedIds = new HashSet<>();
        Map<String, Integer> categoryCounts = new HashMap<>();
        Map<String, Integer> authorCounts = new HashMap<>();
        for (ScoredCandidate candidate : sorted) {
            String category = normalize(candidate.book().getCategory());
            String author = normalize(candidate.book().getAuthor());
            if (categoryCounts.getOrDefault(category, 0) >= 2
                    || authorCounts.getOrDefault(author, 0) >= 1) continue;
            selected.add(candidate);
            selectedIds.add(candidate.book().getId());
            categoryCounts.merge(category, 1, Integer::sum);
            authorCounts.merge(author, 1, Integer::sum);
            if (selected.size() == limit) break;
        }
        if (selected.size() < limit) {
            for (ScoredCandidate candidate : sorted) {
                if (selectedIds.add(candidate.book().getId())) selected.add(candidate);
                if (selected.size() == limit) break;
            }
        }
        List<RankedRecommendation> ranked = new ArrayList<>();
        for (int index = 0; index < selected.size(); index++) {
            ScoredCandidate candidate = selected.get(index);
            ranked.add(new RankedRecommendation(index + 1, candidate.book(), candidate.total(),
                    candidate.content(), candidate.collaborative(), candidate.quality(),
                    candidate.exploration(), candidate.sourceType(), candidate.reason()));
        }
        return ranked;
    }

    private boolean same(String left, String right) {
        return !normalize(left).isEmpty() && normalize(left).equals(normalize(right));
    }

    private String normalize(String value) {
        return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
    }

    private String safe(String value) {
        return value == null ? "" : value;
    }

    private int value(Integer value) {
        return value == null ? 0 : value;
    }

    private double decimal(Double value) {
        return value == null || !Double.isFinite(value) ? 0d : value;
    }

    public record RecommendationPlan(String mode,
                                     boolean personalized,
                                     int signalCount,
                                     List<RankedRecommendation> items) {
    }

    public record RankedRecommendation(int rank,
                                       RecommendationBookProfile book,
                                       double totalScore,
                                       double contentScore,
                                       double collaborativeScore,
                                       double qualityScore,
                                       double explorationScore,
                                       String sourceType,
                                       String reason) {
    }

    private record ScoredCandidate(RecommendationBookProfile book,
                                   double total,
                                   double content,
                                   double collaborative,
                                   double quality,
                                   double exploration,
                                   String sourceType,
                                   String reason) {
    }

    private record CollaborativeScores(Map<Integer, Double> scores,
                                       Map<Integer, Integer> sourceBookByCandidate) {
        private static CollaborativeScores empty() {
            return new CollaborativeScores(Map.of(), Map.of());
        }
    }
}