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
@@ -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;
}
/**
* 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。
* 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。