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:
@@ -156,50 +156,6 @@ public class MindMapTreeTool {
|
||||
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) */
|
||||
private static MindMapNode copyNodeShallow(MindMapNode node) {
|
||||
MindMapNode copy = new MindMapNode();
|
||||
@@ -207,10 +163,138 @@ public class MindMapTreeTool {
|
||||
copy.setNotes(node.getNotes());
|
||||
copy.setSourceType(node.getSourceType());
|
||||
copy.setSourceId(node.getSourceId());
|
||||
copy.setSessionNum(node.getSessionNum());
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。
|
||||
* 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。
|
||||
|
||||
Reference in New Issue
Block a user