feat(复习):feed 智能模式——加权随机采样

- /review/feed?mode=smart:时间衰减 × 回忆掌握度加权采样
- 越久未见、回忆覆盖率越低的内容出现概率越高
- 保持随机性,符合复习模块偶遇式设计理念
- 概率性单元测试验证低掌握内容显著优先
This commit is contained in:
2026-07-03 23:44:40 +08:00
parent 7461b6b5ad
commit 3c4db2fead
2 changed files with 161 additions and 0 deletions
@@ -6,6 +6,7 @@ import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats; import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest; import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity; import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity; 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.enums.ReviewMindMapParseStatusEnum;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper; import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper; import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper; import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper; 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 DEFAULT_LIMIT = 30;
private static final int MAX_LIMIT = 100; private static final int MAX_LIMIT = 100;
private static final String RANDOM_MODE = "random"; 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 DEFAULT_MIND_MAP_FORMAT = "TEXT";
private static final String MANUAL_SOURCE_TYPE = "MANUAL"; private static final String MANUAL_SOURCE_TYPE = "MANUAL";
private static final String FILE_SOURCE_TYPE = "FILE"; private static final String FILE_SOURCE_TYPE = "FILE";
@@ -56,11 +60,15 @@ public class ReviewServiceImpl implements ReviewService {
private final StudySessionsMapper studySessionsMapper; private final StudySessionsMapper studySessionsMapper;
private final TasksMapper tasksMapper; private final TasksMapper tasksMapper;
private final ReviewMindMapMapper reviewMindMapMapper; private final ReviewMindMapMapper reviewMindMapMapper;
private final ReviewRecallRecordMapper reviewRecallRecordMapper;
private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper()); private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper());
@Override @Override
public List<ReviewFeedItem> getReviewFeed(int limit, String mode) { public List<ReviewFeedItem> getReviewFeed(int limit, String mode) {
int safeLimit = normalizeLimit(limit); int safeLimit = normalizeLimit(limit);
if (SMART_MODE.equalsIgnoreCase(mode)) {
return getSmartFeed(safeLimit);
}
boolean random = RANDOM_MODE.equalsIgnoreCase(mode); boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
List<StudyReportsEntity> reports = random List<StudyReportsEntity> reports = random
@@ -84,6 +92,97 @@ public class ReviewServiceImpl implements ReviewService {
return items.stream().limit(safeLimit).collect(Collectors.toList()); return items.stream().limit(safeLimit).collect(Collectors.toList());
} }
/**
* 智能模式:加权随机采样,让更需要复习的内容有更高概率出现。
* 权重 = 时间衰减(越久未见权重越高) × 任务回忆掌握度(覆盖率越低权重越高)。
* 仍保持随机性,只是"随机得不那么均匀"——符合复习模块无压力、偶遇式的设计理念。
*/
private List<ReviewFeedItem> getSmartFeed(int safeLimit) {
int candidateLimit = Math.min(safeLimit * SMART_CANDIDATE_MULTIPLIER, 500);
List<StudyReportsEntity> reports = studyReportsMapper.selectList(
Wrappers.<StudyReportsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<StudyReportFragmentsEntity> fragments = studyReportFragmentsMapper.selectList(
Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.last("ORDER BY RAND() LIMIT " + candidateLimit));
List<ReviewFeedItem> candidates = mergeAndConvert(reports, fragments);
if (candidates.size() <= safeLimit) {
Collections.shuffle(candidates);
return candidates;
}
// 各任务最近一次回忆对比的覆盖率(没有记录视为 0 = 最需要复习)
Map<String, Double> taskRecallRatio = latestRecallRatioByTask(
candidates.stream().map(ReviewFeedItem::getTaskNum)
.filter(Objects::nonNull).collect(Collectors.toSet()));
LocalDateTime now = LocalDateTime.now();
List<Double> 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<String, Double> 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<String, Double> latestRecallRatioByTask(Set<String> taskNums) {
if (taskNums.isEmpty()) {
return Collections.emptyMap();
}
List<ReviewRecallRecordEntity> records = reviewRecallRecordMapper.selectList(
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
.in(ReviewRecallRecordEntity::getTaskNum, taskNums)
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
Map<String, Double> result = new HashMap<>();
for (ReviewRecallRecordEntity record : records) {
result.putIfAbsent(record.getTaskNum(),
record.getRecallRatio() == null ? 0D : record.getRecallRatio());
}
return result;
}
/** 按权重不放回采样 */
private List<ReviewFeedItem> weightedSample(List<ReviewFeedItem> items, List<Double> weights, int count) {
List<ReviewFeedItem> pool = new ArrayList<>(items);
List<Double> poolWeights = new ArrayList<>(weights);
List<ReviewFeedItem> 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 @Override
public List<ReviewTaskStats> getReviewTaskStats() { public List<ReviewTaskStats> getReviewTaskStats() {
List<TaskEntity> tasks = tasksMapper.selectList( List<TaskEntity> tasks = tasksMapper.selectList(
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.guo.learningprogresstracker.dto.ReviewFeedItem; import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest; import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity; import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity; import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity; import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity; 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.ReviewMindMapFileFormatEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum; import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper; import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
import com.guo.learningprogresstracker.mapper.ReviewRecallRecordMapper;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper; import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper; import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper; import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
@@ -67,6 +69,66 @@ class ReviewServiceImplTest {
@Mock @Mock
private ReviewMindMapMapper reviewMindMapMapper; 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<ReviewFeedItem> items = reviewService.getReviewFeed(1, "smart");
// 采样是概率性的,多次运行统计低掌握内容被选中的频率应显著更高
int lowMasteryWins = 0;
for (int i = 0; i < 200; i++) {
List<ReviewFeedItem> 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 @Test
void getReviewFeed_shouldApplyFinalLimitAfterMerge() { void getReviewFeed_shouldApplyFinalLimitAfterMerge() {
StudyReportsEntity report = new StudyReportsEntity(); StudyReportsEntity report = new StudyReportsEntity();