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:
@@ -24,7 +24,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.List;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复习模块控制层 — 复习内容滚动展示与交互
|
* 复习模块控制层 — 复习内容滚动展示与交互
|
||||||
@@ -159,7 +159,18 @@ public class ReviewController {
|
|||||||
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
|
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
|
||||||
@PathVariable String taskNum,
|
@PathVariable String taskNum,
|
||||||
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
|
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
|
||||||
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getSessionNum()));
|
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getFocusPath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在标准导图中查找与给定内容最匹配的节点
|
||||||
|
*/
|
||||||
|
@PostMapping("/standard-mind-map/{taskNum}/find-node")
|
||||||
|
public CommonResult<Map<String, Object>> findNode(
|
||||||
|
@PathVariable String taskNum,
|
||||||
|
@RequestBody Map<String, String> body) throws NotFindEntitiesException, OperationFailedException {
|
||||||
|
String content = body != null ? body.getOrDefault("content", "") : "";
|
||||||
|
return CommonResult.success(standardMindMapService.findNode(taskNum, content));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,10 +137,4 @@ public class StudySessionController {
|
|||||||
@RequestParam(required = false) String excludeTaskNum) {
|
@RequestParam(required = false) String excludeTaskNum) {
|
||||||
return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum));
|
return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取任务的所有会话简要列表(用于复习维度选择) */
|
|
||||||
@GetMapping("/tasks/{taskNum}/sessions")
|
|
||||||
public CommonResult<List<SessionBriefVO>> getTaskSessions(@PathVariable String taskNum) {
|
|
||||||
return CommonResult.success(studySessionsServiceImpl.getTaskSessions(taskNum));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ public class RecallCompareRequest {
|
|||||||
@NotBlank(message = "回忆大纲不可为空")
|
@NotBlank(message = "回忆大纲不可为空")
|
||||||
private String recallOutline;
|
private String recallOutline;
|
||||||
|
|
||||||
/** 复习维度:会话编码,null 为任务维度 */
|
/** 复习起点节点路径(以 / 分隔),null 为任务维度 */
|
||||||
private String sessionNum;
|
private String focusPath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.guo.learningprogresstracker.dto.response;
|
|
||||||
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 会话简要信息(用于复习维度选择列表)
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
public class SessionBriefVO {
|
|
||||||
private String sessionNum;
|
|
||||||
private String startTime;
|
|
||||||
private String expectation;
|
|
||||||
private int reportCount;
|
|
||||||
private int fragmentCount;
|
|
||||||
}
|
|
||||||
@@ -25,10 +25,10 @@ public class ReviewRecallRecordEntity extends BaseEntity implements Serializable
|
|||||||
private Integer standardMapId;
|
private Integer standardMapId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复习维度:会话编码(null 表示任务维度复习)
|
* 复习起点节点路径(以 / 分隔),null 表示任务维度复习
|
||||||
*/
|
*/
|
||||||
@TableField(value = "session_num")
|
@TableField(value = "focus_path")
|
||||||
private String sessionNum;
|
private String focusPath;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户回忆绘制的导图大纲文本
|
* 用户回忆绘制的导图大纲文本
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
|
|||||||
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
import com.guo.learningprogresstracker.exception.OperationFailedException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标准思维导图服务。
|
* 标准思维导图服务。
|
||||||
@@ -37,10 +38,16 @@ public interface StandardMindMapService {
|
|||||||
* 用户提交回忆大纲,与标准导图对比并返回结果。
|
* 用户提交回忆大纲,与标准导图对比并返回结果。
|
||||||
*
|
*
|
||||||
* @param recallOutline 用户回忆的缩进大纲文本
|
* @param recallOutline 用户回忆的缩进大纲文本
|
||||||
* @param sessionNum 可选会话编码,指定时仅对比该会话相关的标准导图节点
|
* @param focusPath 可选:标准导图中的节点路径(/ 分隔),指定后仅对比该子树
|
||||||
* @return 对比记录实体(compareResult 字段包含对比树 JSON)
|
|
||||||
*/
|
*/
|
||||||
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 com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author guo
|
* @author guo
|
||||||
@@ -37,7 +35,4 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
|
|||||||
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
|
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
|
||||||
|
|
||||||
StudySessionResponse getActiveSession(String excludeTaskNum);
|
StudySessionResponse getActiveSession(String excludeTaskNum);
|
||||||
|
|
||||||
/** 获取任务的所有会话简要信息(复习维度选择用) */
|
|
||||||
List<SessionBriefVO> getTaskSessions(String taskNum);
|
|
||||||
}
|
}
|
||||||
|
|||||||
-3
@@ -84,7 +84,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
||||||
sessionNode.setSessionNum(sessionNum);
|
|
||||||
|
|
||||||
// 报告作为子节点
|
// 报告作为子节点
|
||||||
for (StudyReportsEntity report : sessReports) {
|
for (StudyReportsEntity report : sessReports) {
|
||||||
@@ -94,7 +93,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
reportNode.setNotes(content);
|
reportNode.setNotes(content);
|
||||||
reportNode.setSourceType("REPORT");
|
reportNode.setSourceType("REPORT");
|
||||||
reportNode.setSourceId(report.getId());
|
reportNode.setSourceId(report.getId());
|
||||||
reportNode.setSessionNum(sessionNum);
|
|
||||||
sessionNode.getChildren().add(reportNode);
|
sessionNode.getChildren().add(reportNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +104,6 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
fragNode.setNotes(content);
|
fragNode.setNotes(content);
|
||||||
fragNode.setSourceType("FRAGMENT");
|
fragNode.setSourceType("FRAGMENT");
|
||||||
fragNode.setSourceId(frag.getId());
|
fragNode.setSourceId(frag.getId());
|
||||||
fragNode.setSessionNum(sessionNum);
|
|
||||||
sessionNode.getChildren().add(fragNode);
|
sessionNode.getChildren().add(fragNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-9
@@ -152,7 +152,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum)
|
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String focusPath)
|
||||||
throws NotFindEntitiesException, OperationFailedException {
|
throws NotFindEntitiesException, OperationFailedException {
|
||||||
ensureTaskExists(taskNum);
|
ensureTaskExists(taskNum);
|
||||||
|
|
||||||
@@ -163,9 +163,14 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
|
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
|
||||||
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
|
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
|
||||||
|
|
||||||
// 2b. 若指定了会话维度,过滤标准导图只保留该会话相关节点
|
// 2b. 若指定了起点节点路径,提取该子树作为对比基准
|
||||||
if (sessionNum != null && !sessionNum.isBlank()) {
|
MindMapNode compareRoot = standardRoot;
|
||||||
standardRoot = MindMapTreeTool.filterBySession(standardRoot, sessionNum);
|
if (focusPath != null && !focusPath.isBlank()) {
|
||||||
|
compareRoot = MindMapTreeTool.extractSubtree(standardRoot, focusPath);
|
||||||
|
if (compareRoot == null) {
|
||||||
|
log.warn("focusPath 未匹配到节点,使用全量标准导图: path={}", focusPath);
|
||||||
|
compareRoot = standardRoot;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
|
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
|
||||||
@@ -176,19 +181,19 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
.getTaskName();
|
.getTaskName();
|
||||||
var optJson = aiServiceClient.compareRecall(
|
var optJson = aiServiceClient.compareRecall(
|
||||||
taskName,
|
taskName,
|
||||||
MindMapTreeTool.toFullOutline(standardRoot),
|
MindMapTreeTool.toFullOutline(compareRoot),
|
||||||
recallOutline
|
recallOutline
|
||||||
);
|
);
|
||||||
if (optJson.isPresent()) {
|
if (optJson.isPresent()) {
|
||||||
result = buildCompareResultFromAI(optJson.get(), standardRoot);
|
result = buildCompareResultFromAI(optJson.get(), compareRoot);
|
||||||
log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}",
|
log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}",
|
||||||
taskNum, result.getRecallRatio(),
|
taskNum, result.getRecallRatio(),
|
||||||
result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : "");
|
result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : "");
|
||||||
} else {
|
} else {
|
||||||
result = compareTrees(standardRoot, recallRoot);
|
result = compareTrees(compareRoot, recallRoot);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = compareTrees(standardRoot, recallRoot);
|
result = compareTrees(compareRoot, recallRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 序列化对比结果
|
// 4. 序列化对比结果
|
||||||
@@ -203,7 +208,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
|
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
|
||||||
record.setTaskNum(taskNum);
|
record.setTaskNum(taskNum);
|
||||||
record.setStandardMapId(standard.getId());
|
record.setStandardMapId(standard.getId());
|
||||||
record.setSessionNum(sessionNum != null && !sessionNum.isBlank() ? sessionNum : null);
|
record.setFocusPath(focusPath != null && !focusPath.isBlank() ? focusPath : null);
|
||||||
record.setRecallContent(recallOutline);
|
record.setRecallContent(recallOutline);
|
||||||
record.setCompareResult(resultJson);
|
record.setCompareResult(resultJson);
|
||||||
record.setRecallRatio(result.getRecallRatio());
|
record.setRecallRatio(result.getRecallRatio());
|
||||||
@@ -219,6 +224,29 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
return standard;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ 对比算法核心 ============
|
// ============ 对比算法核心 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
-30
@@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.google.protobuf.ServiceException;
|
import com.google.protobuf.ServiceException;
|
||||||
import com.guo.learningprogresstracker.dto.StudySessionsDto;
|
import com.guo.learningprogresstracker.dto.StudySessionsDto;
|
||||||
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
|
|
||||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
@@ -27,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -227,34 +225,6 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
return result;
|
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 活跃会话查询 ============
|
// ============ 活跃会话查询 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ public class MindMapNode {
|
|||||||
private String notes;
|
private String notes;
|
||||||
private String sourceType;
|
private String sourceType;
|
||||||
private Integer sourceId;
|
private Integer sourceId;
|
||||||
/** 可选:节点来源会话编码(REPORT/FRAGMENT 节点使用) */
|
|
||||||
private String sessionNum;
|
|
||||||
private List<MindMapNode> children;
|
private List<MindMapNode> children;
|
||||||
|
|
||||||
public MindMapNode(String title) {
|
public MindMapNode(String title) {
|
||||||
@@ -53,7 +51,6 @@ public class MindMapNode {
|
|||||||
map.put("notes", notes != null ? notes : "");
|
map.put("notes", notes != null ? notes : "");
|
||||||
if (sourceType != null) map.put("sourceType", sourceType);
|
if (sourceType != null) map.put("sourceType", sourceType);
|
||||||
if (sourceId != null) map.put("sourceId", sourceId);
|
if (sourceId != null) map.put("sourceId", sourceId);
|
||||||
if (sessionNum != null) map.put("sessionNum", sessionNum);
|
|
||||||
List<Object> childMaps = new ArrayList<>();
|
List<Object> childMaps = new ArrayList<>();
|
||||||
if (children != null) {
|
if (children != null) {
|
||||||
for (MindMapNode c : children) {
|
for (MindMapNode c : children) {
|
||||||
@@ -73,7 +70,6 @@ public class MindMapNode {
|
|||||||
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
|
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
|
||||||
node.setSourceId(((Number) map.get("sourceId")).intValue());
|
node.setSourceId(((Number) map.get("sourceId")).intValue());
|
||||||
}
|
}
|
||||||
node.setSessionNum((String) map.get("sessionNum"));
|
|
||||||
Object raw = map.getOrDefault("children", map.get("nodes"));
|
Object raw = map.getOrDefault("children", map.get("nodes"));
|
||||||
List<MindMapNode> children = new ArrayList<>();
|
List<MindMapNode> children = new ArrayList<>();
|
||||||
if (raw instanceof List<?> list) {
|
if (raw instanceof List<?> list) {
|
||||||
|
|||||||
@@ -156,50 +156,6 @@ public class MindMapTreeTool {
|
|||||||
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
|
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 过滤树:只保留与指定 sessionNum 相关的分支。
|
|
||||||
* - 节点有 sessionNum 且匹配 → 保留
|
|
||||||
* - 节点无 sessionNum(如 APPLICATION 或 AI 生成的节点) → 保留(视为任务级通用知识)
|
|
||||||
* - 有子节点被保留时,父节点也保留
|
|
||||||
*
|
|
||||||
* @param root 树根
|
|
||||||
* @param sessionNum 目标会话编码
|
|
||||||
* @return 过滤后的树,若无相关节点则返回仅含根的空树
|
|
||||||
*/
|
|
||||||
public static MindMapNode filterBySession(MindMapNode root, String sessionNum) {
|
|
||||||
if (root == null || sessionNum == null || sessionNum.isBlank()) return root;
|
|
||||||
MindMapNode filtered = filterRecursive(root, sessionNum);
|
|
||||||
return filtered != null ? filtered : new MindMapNode(root.getTitle() != null ? root.getTitle() : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MindMapNode filterRecursive(MindMapNode node, String sessionNum) {
|
|
||||||
if (node == null) return null;
|
|
||||||
|
|
||||||
// 先过滤子节点
|
|
||||||
List<MindMapNode> keptChildren = new ArrayList<>();
|
|
||||||
if (node.getChildren() != null) {
|
|
||||||
for (MindMapNode child : node.getChildren()) {
|
|
||||||
MindMapNode kept = filterRecursive(child, sessionNum);
|
|
||||||
if (kept != null) {
|
|
||||||
keptChildren.add(kept);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断当前节点是否应该保留:
|
|
||||||
// 1. 有 sessionNum 且匹配 → 保留
|
|
||||||
// 2. 无 sessionNum → 保留(任务级节点,如 APPLICATION 或 AI 生成)
|
|
||||||
// 3. 有子节点保留 → 保留
|
|
||||||
boolean selfMatch = (node.getSessionNum() == null) || sessionNum.equals(node.getSessionNum());
|
|
||||||
if (selfMatch || !keptChildren.isEmpty()) {
|
|
||||||
MindMapNode result = copyNodeShallow(node);
|
|
||||||
result.setChildren(keptChildren.isEmpty() ? node.getChildren() : keptChildren);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */
|
/** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */
|
||||||
private static MindMapNode copyNodeShallow(MindMapNode node) {
|
private static MindMapNode copyNodeShallow(MindMapNode node) {
|
||||||
MindMapNode copy = new MindMapNode();
|
MindMapNode copy = new MindMapNode();
|
||||||
@@ -207,10 +163,138 @@ public class MindMapTreeTool {
|
|||||||
copy.setNotes(node.getNotes());
|
copy.setNotes(node.getNotes());
|
||||||
copy.setSourceType(node.getSourceType());
|
copy.setSourceType(node.getSourceType());
|
||||||
copy.setSourceId(node.getSourceId());
|
copy.setSourceId(node.getSourceId());
|
||||||
copy.setSessionNum(node.getSessionNum());
|
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按路径提取子树:以 / 分隔的节点标题路径,提取目标节点为根的新树。
|
||||||
|
* @param root 完整树根
|
||||||
|
* @param path 节点路径,如 "根标题 / 分支A / 子节点B"
|
||||||
|
* @return 以路径末端节点为根的深拷贝子树,匹配失败时返回 null
|
||||||
|
*/
|
||||||
|
public static MindMapNode extractSubtree(MindMapNode root, String path) {
|
||||||
|
if (root == null || path == null || path.isBlank()) return null;
|
||||||
|
String[] segments = path.split("\\s*/\\s*");
|
||||||
|
MindMapNode current = root;
|
||||||
|
for (int i = 0; i < segments.length; i++) {
|
||||||
|
String seg = segments[i].trim();
|
||||||
|
if (seg.isEmpty()) continue;
|
||||||
|
if (current.getTitle() != null && normalizeForMerge(current.getTitle()).equals(normalizeForMerge(seg))) {
|
||||||
|
continue; // 当前节点已匹配,看下一段
|
||||||
|
}
|
||||||
|
MindMapNode found = null;
|
||||||
|
if (current.getChildren() != null) {
|
||||||
|
for (MindMapNode child : current.getChildren()) {
|
||||||
|
if (child.getTitle() != null && normalizeForMerge(child.getTitle()).equals(normalizeForMerge(seg))) {
|
||||||
|
found = child;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found != null) {
|
||||||
|
current = found;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deepCopy(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 深拷贝节点及其子树 */
|
||||||
|
private static MindMapNode deepCopy(MindMapNode node) {
|
||||||
|
if (node == null) return null;
|
||||||
|
MindMapNode copy = new MindMapNode();
|
||||||
|
copy.setTitle(node.getTitle());
|
||||||
|
copy.setNotes(node.getNotes());
|
||||||
|
copy.setSourceType(node.getSourceType());
|
||||||
|
copy.setSourceId(node.getSourceId());
|
||||||
|
List<MindMapNode> children = new ArrayList<>();
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
children.add(deepCopy(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copy.setChildren(children);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找与 content 最相似的节点。基于 title 的 bigram Jaccard 相似度。
|
||||||
|
* @return 得分最高的节点,若所有节点得分均低于 0.1 则返回 null
|
||||||
|
*/
|
||||||
|
public static MindMapNode findClosestNode(MindMapNode root, String content) {
|
||||||
|
if (root == null || content == null || content.isBlank()) return null;
|
||||||
|
List<MindMapNode> flat = flatten(root);
|
||||||
|
MindMapNode best = null;
|
||||||
|
double bestScore = 0.0;
|
||||||
|
for (MindMapNode node : flat) {
|
||||||
|
if (node.getTitle() == null || node.getTitle().isBlank()) continue;
|
||||||
|
double score = similarityScore(node.getTitle(), content);
|
||||||
|
// notes 也参与匹配,但权重减半
|
||||||
|
if (node.getNotes() != null && !node.getNotes().isBlank()) {
|
||||||
|
double noteScore = similarityScore(node.getNotes(), content);
|
||||||
|
score = Math.max(score, noteScore * 0.5);
|
||||||
|
}
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestScore > 0.1 ? best : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取节点在树中的路径(以 / 分隔的 title 序列)
|
||||||
|
*/
|
||||||
|
public static String getPath(MindMapNode root, String targetTitle) {
|
||||||
|
if (root == null || targetTitle == null) return "";
|
||||||
|
List<String> path = new ArrayList<>();
|
||||||
|
if (findPathRecursive(root, targetTitle, path)) {
|
||||||
|
return String.join(" / ", path);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean findPathRecursive(MindMapNode node, String targetTitle, List<String> path) {
|
||||||
|
if (node == null) return false;
|
||||||
|
path.add(node.getTitle() != null ? node.getTitle() : "");
|
||||||
|
if (normalizeForMerge(node.getTitle()).equals(normalizeForMerge(targetTitle))) return true;
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
if (findPathRecursive(child, targetTitle, path)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.remove(path.size() - 1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bigram Jaccard 相似度 */
|
||||||
|
public static double similarityScore(String a, String b) {
|
||||||
|
if (a == null || b == null) return 0;
|
||||||
|
String na = normalizeForMerge(a);
|
||||||
|
String nb = normalizeForMerge(b);
|
||||||
|
if (na.isEmpty() || nb.isEmpty()) return 0;
|
||||||
|
if (na.equals(nb)) return 1.0;
|
||||||
|
// 子串匹配
|
||||||
|
if (na.contains(nb) || nb.contains(na)) return 0.9;
|
||||||
|
Set<String> bigramsA = bigrams(na);
|
||||||
|
Set<String> bigramsB = bigrams(nb);
|
||||||
|
if (bigramsA.isEmpty() && bigramsB.isEmpty()) return 0;
|
||||||
|
Set<String> union = new HashSet<>(bigramsA);
|
||||||
|
union.addAll(bigramsB);
|
||||||
|
Set<String> intersect = new HashSet<>(bigramsA);
|
||||||
|
intersect.retainAll(bigramsB);
|
||||||
|
return (double) intersect.size() / union.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> bigrams(String s) {
|
||||||
|
Set<String> set = new LinkedHashSet<>();
|
||||||
|
for (int i = 0; i < s.length() - 1; i++) {
|
||||||
|
set.add(s.substring(i, i + 2));
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。
|
* 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。
|
||||||
* 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。
|
* 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 为回忆对比记录添加 focus_path 字段,支持节点级复习范围
|
||||||
|
ALTER TABLE review_recall_records
|
||||||
|
ADD COLUMN focus_path VARCHAR(512) NULL AFTER session_num;
|
||||||
Reference in New Issue
Block a user