diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java b/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java index 029360b..b118e34 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java @@ -6,6 +6,7 @@ import com.guo.learningprogresstracker.dto.ReviewFeedItem; import com.guo.learningprogresstracker.dto.ReviewTaskStats; import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest; import com.guo.learningprogresstracker.entity.ReviewMindMapEntity; +import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudySessionsEntity; @@ -14,6 +15,7 @@ import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum; import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum; import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper; +import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper; import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper; import com.guo.learningprogresstracker.mapper.StudyReportsMapper; import com.guo.learningprogresstracker.mapper.StudySessionsMapper; @@ -47,6 +49,8 @@ public class ReviewServiceImpl implements ReviewService { private static final int DEFAULT_LIMIT = 30; private static final int MAX_LIMIT = 100; private static final String RANDOM_MODE = "random"; + private static final String SMART_MODE = "smart"; + private static final int SMART_CANDIDATE_MULTIPLIER = 5; private static final String DEFAULT_MIND_MAP_FORMAT = "TEXT"; private static final String MANUAL_SOURCE_TYPE = "MANUAL"; private static final String FILE_SOURCE_TYPE = "FILE"; @@ -56,11 +60,15 @@ public class ReviewServiceImpl implements ReviewService { private final StudySessionsMapper studySessionsMapper; private final TasksMapper tasksMapper; private final ReviewMindMapMapper reviewMindMapMapper; + private final ReviewRecallRecordMapper reviewRecallRecordMapper; private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper()); @Override public List getReviewFeed(int limit, String mode) { int safeLimit = normalizeLimit(limit); + if (SMART_MODE.equalsIgnoreCase(mode)) { + return getSmartFeed(safeLimit); + } boolean random = RANDOM_MODE.equalsIgnoreCase(mode); List reports = random @@ -84,6 +92,97 @@ public class ReviewServiceImpl implements ReviewService { return items.stream().limit(safeLimit).collect(Collectors.toList()); } + /** + * 智能模式:加权随机采样,让更需要复习的内容有更高概率出现。 + * 权重 = 时间衰减(越久未见权重越高) × 任务回忆掌握度(覆盖率越低权重越高)。 + * 仍保持随机性,只是"随机得不那么均匀"——符合复习模块无压力、偶遇式的设计理念。 + */ + private List getSmartFeed(int safeLimit) { + int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500); + + List reports = studyReportsMapper.selectList( + Wrappers.lambdaQuery() + .last("ORDER BY RAND() LIMIT " + candidateLimit)); + List fragments = studyReportFragmentsMapper.selectList( + Wrappers.lambdaQuery() + .last("ORDER BY RAND() LIMIT " + candidateLimit)); + + List candidates = mergeAndConvert(reports, fragments); + if (candidates.size() <= safeLimit) { + Collections.shuffle(candidates); + return candidates; + } + + // 各任务最近一次回忆对比的覆盖率(没有记录视为 0 = 最需要复习) + Map taskRecallRatio = latestRecallRatioByTask( + candidates.stream().map(ReviewFeedItem::getTaskNum) + .filter(Objects::nonNull).collect(Collectors.toSet())); + + LocalDateTime now = LocalDateTime.now(); + List weights = new ArrayList<>(candidates.size()); + for (ReviewFeedItem item : candidates) { + weights.add(reviewNeedWeight(item, taskRecallRatio, now)); + } + return weightedSample(candidates, weights, safeLimit); + } + + /** 单条内容的复习需求权重 */ + private double reviewNeedWeight(ReviewFeedItem item, Map taskRecallRatio, LocalDateTime now) { + // 时间衰减:7 天内权重较低,之后随天数增长,90 天封顶 + double ageDays = item.getCreatedTime() == null ? 30 + : Math.max(0, java.time.Duration.between(item.getCreatedTime(), now).toDays()); + double ageFactor = 0.3 + Math.min(ageDays, 90) / 90.0 * 0.7; + + // 掌握度:最近回忆覆盖率越低,权重越高 + double ratio = item.getTaskNum() == null ? 0 + : taskRecallRatio.getOrDefault(item.getTaskNum(), 0D); + double masteryFactor = 1.0 - ratio * 0.7; // 覆盖率 100% 时权重降至 0.3 + + return ageFactor * masteryFactor; + } + + /** 查询每个任务最近一次回忆对比的覆盖率 */ + private Map latestRecallRatioByTask(Set taskNums) { + if (taskNums.isEmpty()) { + return Collections.emptyMap(); + } + List records = reviewRecallRecordMapper.selectList( + Wrappers.lambdaQuery() + .in(ReviewRecallRecordEntity::getTaskNum, taskNums) + .orderByDesc(ReviewRecallRecordEntity::getCreatedTime)); + Map result = new HashMap<>(); + for (ReviewRecallRecordEntity record : records) { + result.putIfAbsent(record.getTaskNum(), + record.getRecallRatio() == null ? 0D : record.getRecallRatio()); + } + return result; + } + + /** 按权重不放回采样 */ + private List weightedSample(List items, List weights, int count) { + List pool = new ArrayList<>(items); + List poolWeights = new ArrayList<>(weights); + List selected = new ArrayList<>(count); + Random random = new Random(); + + while (selected.size() < count && !pool.isEmpty()) { + double total = poolWeights.stream().mapToDouble(Double::doubleValue).sum(); + double r = random.nextDouble() * total; + double cumulative = 0; + int chosen = pool.size() - 1; + for (int i = 0; i < pool.size(); i++) { + cumulative += poolWeights.get(i); + if (r <= cumulative) { + chosen = i; + break; + } + } + selected.add(pool.remove(chosen)); + poolWeights.remove(chosen); + } + return selected; + } + @Override public List getReviewTaskStats() { List tasks = tasksMapper.selectList( diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java index 8483f97..6a975cb 100644 --- a/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java @@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.guo.learningprogresstracker.dto.ReviewFeedItem; import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest; import com.guo.learningprogresstracker.entity.ReviewMindMapEntity; +import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudySessionsEntity; @@ -12,6 +13,7 @@ import com.guo.learningprogresstracker.entity.TaskEntity; import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum; import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum; import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper; +import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper; import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper; import com.guo.learningprogresstracker.mapper.StudyReportsMapper; import com.guo.learningprogresstracker.mapper.StudySessionsMapper; @@ -67,6 +69,66 @@ class ReviewServiceImplTest { @Mock private ReviewMindMapMapper reviewMindMapMapper; + @Mock + private ReviewRecallRecordMapper reviewRecallRecordMapper; + + @Test + void getReviewFeed_smartMode_prefersLowRecallRatioContent() { + LocalDateTime now = LocalDateTime.now(); + + StudyReportsEntity oldLowMastery = new StudyReportsEntity(); + oldLowMastery.setId(1); + oldLowMastery.setSessionNum("S_LOW"); + oldLowMastery.setContent("很久没复习且掌握度低"); + oldLowMastery.setCreatedTime(now.minusDays(60)); + + StudyReportsEntity freshHighMastery = new StudyReportsEntity(); + freshHighMastery.setId(2); + freshHighMastery.setSessionNum("S_HIGH"); + freshHighMastery.setContent("刚学完且掌握度高"); + freshHighMastery.setCreatedTime(now); + + StudySessionsEntity sessionLow = new StudySessionsEntity(); + sessionLow.setSessionNum("S_LOW"); + sessionLow.setTaskNum("T_LOW"); + StudySessionsEntity sessionHigh = new StudySessionsEntity(); + sessionHigh.setSessionNum("S_HIGH"); + sessionHigh.setTaskNum("T_HIGH"); + + TaskEntity taskLow = new TaskEntity(); + taskLow.setTaskNum("T_LOW"); + taskLow.setTaskName("低掌握任务"); + TaskEntity taskHigh = new TaskEntity(); + taskHigh.setTaskNum("T_HIGH"); + taskHigh.setTaskName("高掌握任务"); + + ReviewRecallRecordEntity highRatioRecord = new ReviewRecallRecordEntity(); + highRatioRecord.setTaskNum("T_HIGH"); + highRatioRecord.setRecallRatio(1.0); + highRatioRecord.setCreatedTime(now); + + when(studyReportsMapper.selectList(any())).thenReturn(List.of(oldLowMastery, freshHighMastery)); + when(studyReportFragmentsMapper.selectList(any())).thenReturn(List.of()); + when(studySessionsMapper.selectList(any())).thenReturn(List.of(sessionLow, sessionHigh)); + when(tasksMapper.selectList(any())).thenReturn(List.of(taskLow, taskHigh)); + when(reviewRecallRecordMapper.selectList(any())).thenReturn(List.of(highRatioRecord)); + + List items = reviewService.getReviewFeed(1, "smart"); + + // 采样是概率性的,多次运行统计低掌握内容被选中的频率应显著更高 + int lowMasteryWins = 0; + for (int i = 0; i < 200; i++) { + List sample = reviewService.getReviewFeed(1, "smart"); + if (!sample.isEmpty() && "T_LOW".equals(sample.get(0).getTaskNum())) { + lowMasteryWins++; + } + } + assertEquals(1, items.size()); + // 低掌握内容权重约为高掌握内容的 5 倍以上,200 次中至少应赢 120 次 + org.junit.jupiter.api.Assertions.assertTrue(lowMasteryWins > 120, + "低掌握内容被选中次数应显著更多,实际: " + lowMasteryWins + "/200"); + } + @Test void getReviewFeed_shouldApplyFinalLimitAfterMerge() { StudyReportsEntity report = new StudyReportsEntity();