feat(复习):标准思维导图生成与回忆对比

- 新增 review_standard_mind_maps / review_recall_records 数据表
- BuiltinMindMapGenerator:从报告/残片/应用场景规则生成标准导图
- MindMapAiClient 接口 + RemoteAiMindMapClient 预留
- StandardMindMapService:生成/编辑/重新生成/回忆对比
- 对比算法:标题归一化 + Bigram Jaccard 模糊匹配
- ReviewController 新增 6 个端点
- MindMapTreeTool / MindMapNode / CompareResult 工具类
- 完整单元测试(10 个,全部通过)
- 更新 docs/review-module-design.md
This commit is contained in:
2026-07-03 08:53:51 +08:00
parent 5f3f35c53b
commit 32b247526b
34 changed files with 1890 additions and 153 deletions
@@ -3,18 +3,14 @@ package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.ReviewApplicationStatusEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
import com.guo.learningprogresstracker.mapper.ReviewApplicationMapper;
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
@@ -50,7 +46,6 @@ class ReviewServiceImplTest {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudyReportFragmentsEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudySessionsEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), ReviewApplicationEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), ReviewMindMapEntity.class);
}
@@ -69,9 +64,6 @@ class ReviewServiceImplTest {
@Mock
private TasksMapper tasksMapper;
@Mock
private ReviewApplicationMapper reviewApplicationMapper;
@Mock
private ReviewMindMapMapper reviewMindMapMapper;
@@ -109,27 +101,6 @@ class ReviewServiceImplTest {
assertEquals("Task", items.get(0).getTaskName());
}
@Test
void createApplication_shouldUseDefaultStatusWhenStatusInvalid() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
when(reviewApplicationMapper.insert(any())).thenAnswer(invocation -> {
ReviewApplicationEntity entity = invocation.getArgument(0);
entity.setId(7);
return 1;
});
CreateReviewApplicationRequest request = new CreateReviewApplicationRequest();
request.setTaskNum("T1");
request.setTitle("Build a demo");
request.setStatus("invalid");
ReviewApplicationEntity entity = reviewService.createApplication(request);
assertEquals(7, entity.getId());
assertEquals(ReviewApplicationStatusEnum.TODO.getCode(), entity.getStatus());
verify(reviewApplicationMapper).insert(any(ReviewApplicationEntity.class));
}
@Test
void upsertMindMap_shouldCreateWhenMissing() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
@@ -0,0 +1,195 @@
package com.guo.learningprogresstracker.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.*;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.CompareResult;
import com.guo.learningprogresstracker.utils.MindMapNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class StandardMindMapServiceImplTest {
@InjectMocks
private StandardMindMapServiceImpl service;
@Mock
private ReviewStandardMindMapMapper standardMindMapMapper;
@Mock
private ReviewRecallRecordMapper recallRecordMapper;
@Mock
private TasksMapper tasksMapper;
@Mock
private StudyReportsMapper studyReportsMapper;
@Mock
private StudyReportFragmentsMapper studyReportFragmentsMapper;
@Mock
private TaskApplicationMapper taskApplicationMapper;
@Mock
private StudySessionsMapper studySessionsMapper;
@Mock
private List<MindMapAiClient> aiClients;
@Mock
private MindMapAiClient mockAiClient;
private final ObjectMapper objectMapper = new ObjectMapper();
// ============ normalize / fuzzyMatch / bigramSet ============
@Test
void normalize_shouldStripPunctuationAndLowercase() {
assertEquals("javaconcurrency", StandardMindMapServiceImpl.normalize("Java Concurrency"));
assertEquals("测试abc", StandardMindMapServiceImpl.normalize("测试,ABC!?"));
assertEquals("helloworld", StandardMindMapServiceImpl.normalize(" Hello, World! "));
assertEquals("", StandardMindMapServiceImpl.normalize(null));
assertEquals("", StandardMindMapServiceImpl.normalize(""));
}
@Test
void normalize_shouldStripCompareMarkers() {
assertEquals("thread", StandardMindMapServiceImpl.normalize("MATCHED|Thread"));
assertEquals("thread", StandardMindMapServiceImpl.normalize("MISSED|Thread"));
}
@Test
void bigramSet_shouldExtractCharacterPairs() {
java.util.Set<String> set = StandardMindMapServiceImpl.bigramSet("hello");
assertTrue(set.contains("he"));
assertTrue(set.contains("el"));
assertTrue(set.contains("ll"));
assertTrue(set.contains("lo"));
assertEquals(4, set.size());
}
@Test
void bigramSet_shouldReturnEmptyForShortStrings() {
assertTrue(StandardMindMapServiceImpl.bigramSet("a").isEmpty());
assertTrue(StandardMindMapServiceImpl.bigramSet("").isEmpty());
}
// ============ compareTrees ============
@Test
void compareTrees_perfectMatch() {
service = createService();
MindMapNode standard = new MindMapNode("学习Java");
standard.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
MindMapNode recall = new MindMapNode("学习Java");
recall.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(2, result.getMatchedCount());
assertEquals(0, result.getMissedCount());
assertEquals(0, result.getExtraCount());
assertEquals(1.0, result.getRecallRatio(), 0.001);
}
@Test
void compareTrees_partialMatch() {
service = createService();
MindMapNode standard = new MindMapNode("学习Java");
standard.setChildren(List.of(
new MindMapNode("线程基础"),
new MindMapNode("锁机制"),
new MindMapNode("JVM内存模型")
));
MindMapNode recall = new MindMapNode("学习Java");
recall.setChildren(List.of(
new MindMapNode("线程基础"),
new MindMapNode("SpringBoot")
));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(1, result.getMatchedCount());
assertEquals(2, result.getMissedCount());
assertEquals(1, result.getExtraCount());
}
@Test
void compareTrees_noMatch() {
service = createService();
MindMapNode standard = new MindMapNode("学习Java");
standard.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
MindMapNode recall = new MindMapNode("完全不同");
recall.setChildren(List.of(new MindMapNode("前端开发")));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(0, result.getMatchedCount());
assertEquals(2, result.getMissedCount());
assertEquals(1, result.getExtraCount());
assertEquals(0.0, result.getRecallRatio(), 0.001);
}
// ============ 边缘情况 ============
@Test
void compareTrees_emptyStandardLeaves() {
service = createService();
MindMapNode standard = new MindMapNode(""); // 无子节点
MindMapNode recall = new MindMapNode("");
recall.setChildren(List.of(new MindMapNode("额外节点")));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(0, result.getMatchedCount());
assertEquals(0, result.getMissedCount());
assertEquals(1, result.getExtraCount());
assertEquals(0.0, result.getRecallRatio(), 0.001);
}
@Test
void getOrGenerate_shouldThrowWhenTaskNotFound() {
when(tasksMapper.exists(any())).thenReturn(false);
assertThrows(NotFindEntitiesException.class, () -> service.getOrGenerate("NOT_EXIST"));
}
@Test
void getOrGenerate_shouldReturnExisting() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
ReviewStandardMindMapEntity existing = new ReviewStandardMindMapEntity();
existing.setId(1);
existing.setTaskNum("T1");
existing.setTitle("已有导图");
when(standardMindMapMapper.selectOne(any())).thenReturn(existing);
ReviewStandardMindMapEntity result = service.getOrGenerate("T1");
assertEquals("已有导图", result.getTitle());
verify(standardMindMapMapper, never()).insert(any());
}
// ============ 辅助 ============
private StandardMindMapServiceImpl createService() {
StandardMindMapServiceImpl s = new StandardMindMapServiceImpl(
standardMindMapMapper, recallRecordMapper,
tasksMapper, studyReportsMapper, studyReportFragmentsMapper,
taskApplicationMapper, studySessionsMapper,
List.of(mockAiClient), objectMapper);
return s;
}
}
@@ -0,0 +1,63 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
import com.guo.learningprogresstracker.mapper.TaskApplicationMapper;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TasksServiceImplTest {
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskApplicationEntity.class);
}
@InjectMocks
private TasksServiceImpl tasksService;
@Mock
private TasksMapper tasksMapper;
@Mock
private TaskApplicationMapper taskApplicationMapper;
@Test
void createApplication_shouldUseDefaultStatusWhenStatusInvalid() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
when(taskApplicationMapper.insert(any())).thenAnswer(invocation -> {
TaskApplicationEntity entity = invocation.getArgument(0);
entity.setId(7);
return 1;
});
CreateTaskApplicationRequest request = new CreateTaskApplicationRequest();
request.setTaskNum("T1");
request.setTitle("Build a demo");
request.setStatus("invalid");
TaskApplicationEntity entity = tasksService.createApplication(request);
assertEquals(7, entity.getId());
assertEquals(TaskApplicationStatusEnum.TODO.getCode(), entity.getStatus());
verify(taskApplicationMapper).insert(any(TaskApplicationEntity.class));
}
}