feat(复习): 节点级 focusPath + findNode,回退 session 维度

- 移除 MindMapNode.sessionNum、filterBySession、getTaskSessions
- 新增 MindMapTreeTool.extractSubtree() 按路径提取子树作为对比基准
- 新增 findClosestNode/getPath/similarityScore 支持节点匹配
- 新增 POST /review/standard-mind-map/{taskNum}/find-node
- recallCompare 使用 focusPath 替代 sessionNum
- ReviewRecallRecordEntity.focusPath 替代 sessionNum
- Flyway V20260706_1: review_recall_records 加 focus_path 列
This commit is contained in:
2026-07-05 18:12:32 +08:00
parent 3c3f682b2b
commit b18f8b4d1c
13 changed files with 197 additions and 129 deletions
@@ -6,6 +6,7 @@ import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import java.util.List;
import java.util.Map;
/**
* 标准思维导图服务。
@@ -37,10 +38,16 @@ public interface StandardMindMapService {
* 用户提交回忆大纲,与标准导图对比并返回结果。
*
* @param recallOutline 用户回忆的缩进大纲文本
* @param sessionNum 可选会话编码,指定仅对比该会话相关的标准导图节点
* @return 对比记录实体(compareResult 字段包含对比树 JSON
* @param focusPath 可选:标准导图中的节点路径(/ 分隔),指定仅对比该子树
*/
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum) throws NotFindEntitiesException, OperationFailedException;
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath) throws NotFindEntitiesException, OperationFailedException;
/**
* 根据内容文本在标准导图中查找最匹配的节点,返回节点路径。
*
* @return { "path": "节点A / 节点B", "nodeTitle": "节点B", "score": 0.85 }
*/
Map<String, Object> findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException;
/**
* 获取该任务的所有回忆对比记录。
@@ -11,9 +11,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import java.time.LocalDateTime;
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
import java.util.ArrayList;
import java.util.List;
/**
* @author guo
@@ -37,7 +35,4 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
StudySessionResponse getActiveSession(String excludeTaskNum);
/** 获取任务的所有会话简要信息(复习维度选择用) */
List<SessionBriefVO> getTaskSessions(String taskNum);
}
@@ -84,7 +84,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
}
MindMapNode sessionNode = new MindMapNode(sessionTitle);
sessionNode.setSessionNum(sessionNum);
// 报告作为子节点
for (StudyReportsEntity report : sessReports) {
@@ -94,7 +93,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
reportNode.setNotes(content);
reportNode.setSourceType("REPORT");
reportNode.setSourceId(report.getId());
reportNode.setSessionNum(sessionNum);
sessionNode.getChildren().add(reportNode);
}
@@ -106,7 +104,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
fragNode.setNotes(content);
fragNode.setSourceType("FRAGMENT");
fragNode.setSourceId(frag.getId());
fragNode.setSessionNum(sessionNum);
sessionNode.getChildren().add(fragNode);
}
@@ -152,7 +152,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
@Override
@Transactional
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum)
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath)
throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
@@ -163,9 +163,14 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
// 2b. 若指定了会话维度,过滤标准导图只保留该会话相关节点
if (sessionNum != null && !sessionNum.isBlank()) {
standardRoot = MindMapTreeTool.filterBySession(standardRoot, sessionNum);
// 2b. 若指定了起点节点路径,提取该子树作为对比基准
MindMapNode compareRoot = standardRoot;
if (focusPath != null && !focusPath.isBlank()) {
compareRoot = MindMapTreeTool.extractSubtree(standardRoot, focusPath);
if (compareRoot == null) {
log.warn("focusPath 未匹配到节点,使用全量标准导图: path={}", focusPath);
compareRoot = standardRoot;
}
}
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
@@ -176,19 +181,19 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
.getTaskName();
var optJson = aiServiceClient.compareRecall(
taskName,
MindMapTreeTool.toFullOutline(standardRoot),
MindMapTreeTool.toFullOutline(compareRoot),
recallOutline
);
if (optJson.isPresent()) {
result = buildCompareResultFromAI(optJson.get(), standardRoot);
result = buildCompareResultFromAI(optJson.get(), compareRoot);
log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}",
taskNum, result.getRecallRatio(),
result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : "");
} else {
result = compareTrees(standardRoot, recallRoot);
result = compareTrees(compareRoot, recallRoot);
}
} else {
result = compareTrees(standardRoot, recallRoot);
result = compareTrees(compareRoot, recallRoot);
}
// 4. 序列化对比结果
@@ -203,7 +208,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
record.setTaskNum(taskNum);
record.setStandardMapId(standard.getId());
record.setSessionNum(sessionNum != null && !sessionNum.isBlank() ? sessionNum : null);
record.setFocusPath(focusPath != null && !focusPath.isBlank() ? focusPath : null);
record.setRecallContent(recallOutline);
record.setCompareResult(resultJson);
record.setRecallRatio(result.getRecallRatio());
@@ -219,6 +224,29 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
return standard;
}
// ============ 节点查找 ============
@Override
public Map<String, Object> findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
MindMapNode root = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode closest = MindMapTreeTool.findClosestNode(root, content);
Map<String, Object> result = new LinkedHashMap<>();
if (closest != null) {
String path = MindMapTreeTool.getPath(root, closest.getTitle());
result.put("path", path);
result.put("nodeTitle", closest.getTitle());
result.put("score", MindMapTreeTool.similarityScore(closest.getTitle(), content));
} else {
result.put("path", "");
result.put("nodeTitle", "");
result.put("score", 0.0);
}
return result;
}
// ============ 对比算法核心 ============
/**
@@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.dto.StudySessionsDto;
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
@@ -27,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -227,34 +225,6 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
return result;
}
// ============ 获取任务会话列表(复习维度选择用) ============
@Override
public List<SessionBriefVO> getTaskSessions(String taskNum) {
var sessions = this.list(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getTaskNum, taskNum)
.orderByDesc(StudySessionsEntity::getStartTime));
return sessions.stream().map(s -> {
SessionBriefVO vo = new SessionBriefVO();
vo.setSessionNum(s.getSessionNum());
vo.setStartTime(s.getStartTime() != null ? s.getStartTime().toString() : null);
// 查询该会话的预期
Optional.ofNullable(studyExpectationsService.getBySessionNum(s.getSessionNum()))
.ifPresent(e -> vo.setExpectation(e.getDescription()));
// 统计报告和残片数量
long reportCount = studyReportsMapper.selectCount(
Wrappers.lambdaQuery(StudyReportsEntity.class)
.eq(StudyReportsEntity::getSessionNum, s.getSessionNum()));
long fragmentCount = studyReportFragmentsMapper.selectCount(
Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.eq(StudyReportFragmentsEntity::getSessionNum, s.getSessionNum()));
vo.setReportCount((int) reportCount);
vo.setFragmentCount((int) fragmentCount);
return vo;
}).collect(Collectors.toList());
}
// ============ 活跃会话查询 ============
/**