feat(复习):feed 智能模式——加权随机采样
- /review/feed?mode=smart:时间衰减 × 回忆掌握度加权采样 - 越久未见、回忆覆盖率越低的内容出现概率越高 - 保持随机性,符合复习模块偶遇式设计理念 - 概率性单元测试验证低掌握内容显著优先
This commit is contained in:
@@ -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<ReviewFeedItem> getReviewFeed(int limit, String mode) {
|
||||
int safeLimit = normalizeLimit(limit);
|
||||
if (SMART_MODE.equalsIgnoreCase(mode)) {
|
||||
return getSmartFeed(safeLimit);
|
||||
}
|
||||
boolean random = RANDOM_MODE.equalsIgnoreCase(mode);
|
||||
|
||||
List<StudyReportsEntity> reports = random
|
||||
@@ -84,6 +92,97 @@ public class ReviewServiceImpl implements ReviewService {
|
||||
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
|
||||
public List<ReviewTaskStats> getReviewTaskStats() {
|
||||
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.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<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
|
||||
void getReviewFeed_shouldApplyFinalLimitAfterMerge() {
|
||||
StudyReportsEntity report = new StudyReportsEntity();
|
||||
|
||||
Reference in New Issue
Block a user