b18f8b4d1c
- 移除 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 列
376 lines
14 KiB
Java
376 lines
14 KiB
Java
package com.guo.learningprogresstracker.utils;
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.Getter;
|
|
|
|
import java.util.*;
|
|
|
|
/**
|
|
* 思维导图树结构与缩进大纲文本之间的双向转换工具。
|
|
* <p>大纲格式:每行一条节点,缩进表示层级(2 空格 / tab / # 前缀),行首 - * 数字. 等标记被自动剥离。</p>
|
|
*/
|
|
public class MindMapTreeTool {
|
|
|
|
private static final int INDENT_SPACES = 2;
|
|
|
|
private MindMapTreeTool() {
|
|
}
|
|
|
|
// ============ 序列化:树 → 缩进大纲 ============
|
|
|
|
/**
|
|
* 将根节点序列化为缩进大纲文本
|
|
*/
|
|
public static String toOutline(MindMapNode root) {
|
|
StringBuilder sb = new StringBuilder();
|
|
appendOutline(sb, root, 0);
|
|
return sb.toString();
|
|
}
|
|
|
|
private static void appendOutline(StringBuilder sb, MindMapNode node, int level) {
|
|
if (level > 0) {
|
|
sb.append(" ".repeat(level)).append("- ").append(node.getTitle() != null ? node.getTitle() : "").append('\n');
|
|
}
|
|
if (node.getChildren() != null) {
|
|
for (MindMapNode child : node.getChildren()) {
|
|
appendOutline(sb, child, level + 1);
|
|
}
|
|
}
|
|
// level 0 是根节点标题本身不输出,但其子节点输出
|
|
if (level == 0 && node.getChildren() != null) {
|
|
for (MindMapNode child : node.getChildren()) {
|
|
appendOutline(sb, child, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 根节点序列化为完整大纲,第一行为根标题
|
|
*/
|
|
public static String toFullOutline(MindMapNode root) {
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append(root.getTitle() != null ? root.getTitle() : "").append('\n');
|
|
if (root.getChildren() != null) {
|
|
for (MindMapNode child : root.getChildren()) {
|
|
appendOutline(sb, child, 1);
|
|
}
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
// ============ 反序列化:缩进大纲 → 树 ============
|
|
|
|
/**
|
|
* 将缩进大纲文本解析为根节点。
|
|
* 第一行非空文本作为根标题,后续行作为子节点。
|
|
*/
|
|
public static MindMapNode parseOutline(String outlineText) {
|
|
if (outlineText == null || outlineText.isBlank()) {
|
|
return new MindMapNode("未命名");
|
|
}
|
|
String[] lines = outlineText.split("\\R");
|
|
List<String> nonBlank = new ArrayList<>();
|
|
for (String line : lines) {
|
|
if (!line.trim().isBlank()) {
|
|
nonBlank.add(line.stripTrailing());
|
|
}
|
|
}
|
|
if (nonBlank.isEmpty()) {
|
|
return new MindMapNode("未命名");
|
|
}
|
|
|
|
MindMapNode root = new MindMapNode(stripMarker(nonBlank.get(0).strip()));
|
|
List<MindMapNode> roots = new ArrayList<>();
|
|
|
|
Deque<StackEntry> stack = new ArrayDeque<>();
|
|
stack.push(new StackEntry(-1, roots));
|
|
|
|
for (int i = 1; i < nonBlank.size(); i++) {
|
|
String raw = nonBlank.get(i);
|
|
int level = detectLevel(raw);
|
|
String title = stripMarker(raw.strip());
|
|
MindMapNode child = new MindMapNode(title);
|
|
while (stack.peek().level >= level) {
|
|
stack.pop();
|
|
}
|
|
stack.peek().children.add(child);
|
|
List<MindMapNode> children = new ArrayList<>();
|
|
child.setChildren(children);
|
|
stack.push(new StackEntry(level, children));
|
|
}
|
|
|
|
root.setChildren(roots);
|
|
return root;
|
|
}
|
|
|
|
private static int detectLevel(String line) {
|
|
String trimmed = line.stripLeading();
|
|
int indent = line.length() - trimmed.length();
|
|
int hashCount = 0;
|
|
while (hashCount < trimmed.length() && trimmed.charAt(hashCount) == '#') {
|
|
hashCount++;
|
|
}
|
|
if (hashCount > 0) return hashCount;
|
|
if (indent == 0) return 1;
|
|
return Math.max(1, indent / INDENT_SPACES + 1);
|
|
}
|
|
|
|
private static String stripMarker(String s) {
|
|
return s.replaceFirst("^#{1,6}\\s*", "")
|
|
.replaceFirst("^[-*+]\\s*", "")
|
|
.replaceFirst("^\\d+\\.\\s*", "")
|
|
.strip();
|
|
}
|
|
|
|
private record StackEntry(int level, List<MindMapNode> children) {
|
|
}
|
|
|
|
// ============ 工具方法 ============
|
|
|
|
/** 展开树为平铺列表(前序遍历) */
|
|
public static List<MindMapNode> flatten(MindMapNode root) {
|
|
List<MindMapNode> list = new ArrayList<>();
|
|
flattenRecursive(root, list);
|
|
return list;
|
|
}
|
|
|
|
private static void flattenRecursive(MindMapNode node, List<MindMapNode> acc) {
|
|
acc.add(node);
|
|
if (node.getChildren() != null) {
|
|
for (MindMapNode child : node.getChildren()) {
|
|
flattenRecursive(child, acc);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 统计节点总数 */
|
|
public static int countNodes(MindMapNode root) {
|
|
return flatten(root).size();
|
|
}
|
|
|
|
/** 计算最大深度 */
|
|
public static int maxDepth(MindMapNode node) {
|
|
if (node.getChildren() == null || node.getChildren().isEmpty()) {
|
|
return 1;
|
|
}
|
|
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
|
|
}
|
|
|
|
/** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */
|
|
private static MindMapNode copyNodeShallow(MindMapNode node) {
|
|
MindMapNode copy = new MindMapNode();
|
|
copy.setTitle(node.getTitle());
|
|
copy.setNotes(node.getNotes());
|
|
copy.setSourceType(node.getSourceType());
|
|
copy.setSourceId(node.getSourceId());
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 合并新旧树:保留旧树中已有节点(用户编辑),追加新树中的新节点。
|
|
* 按标准化标题匹配同级节点,旧树匹配到的节点优先保留。
|
|
*
|
|
* @param oldRoot 旧树根(用户可能编辑过)
|
|
* @param newRoot 新树根(AI/BUILTIN 最新生成)
|
|
* @return 合并后的树
|
|
*/
|
|
public static MindMapNode mergeTrees(MindMapNode oldRoot, MindMapNode newRoot) {
|
|
if (oldRoot == null) return newRoot;
|
|
if (newRoot == null) return oldRoot;
|
|
MindMapNode result = copyNodeShallow(oldRoot);
|
|
result.setChildren(mergeChildren(oldRoot.getChildren(), newRoot.getChildren()));
|
|
return result;
|
|
}
|
|
|
|
/** 递归合并子节点列表 */
|
|
private static List<MindMapNode> mergeChildren(List<MindMapNode> oldChildren, List<MindMapNode> newChildren) {
|
|
Map<String, MindMapNode> oldByNormalized = new LinkedHashMap<>();
|
|
if (oldChildren != null) {
|
|
for (MindMapNode child : oldChildren) {
|
|
oldByNormalized.put(normalizeForMerge(child.getTitle()), child);
|
|
}
|
|
}
|
|
|
|
List<MindMapNode> merged = new ArrayList<>();
|
|
Set<String> usedKeys = new HashSet<>();
|
|
|
|
if (newChildren != null) {
|
|
for (MindMapNode newNode : newChildren) {
|
|
String key = normalizeForMerge(newNode.getTitle());
|
|
MindMapNode oldNode = oldByNormalized.get(key);
|
|
if (oldNode != null) {
|
|
// 旧节点存在:保留旧节点标题,递归合并子节点
|
|
MindMapNode kept = copyNodeShallow(oldNode);
|
|
kept.setChildren(mergeChildren(oldNode.getChildren(), newNode.getChildren()));
|
|
merged.add(kept);
|
|
usedKeys.add(key);
|
|
} else {
|
|
// 新节点:直接添加
|
|
merged.add(newNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 添加旧树中有但新树中没有的节点(用户添加的额外节点)
|
|
if (oldChildren != null) {
|
|
for (MindMapNode oldNode : oldChildren) {
|
|
if (!usedKeys.contains(normalizeForMerge(oldNode.getTitle()))) {
|
|
merged.add(oldNode);
|
|
}
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
private static String normalizeForMerge(String s) {
|
|
if (s == null) return "";
|
|
return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "")
|
|
.toLowerCase(Locale.ROOT)
|
|
.trim();
|
|
}
|
|
|
|
/** 序列化整棵树为 JSON */
|
|
public static String toJson(MindMapNode root, ObjectMapper mapper) {
|
|
try {
|
|
return mapper.writeValueAsString(root.toMap());
|
|
} catch (Exception e) {
|
|
return "{\"title\":\"序列化失败\"}";
|
|
}
|
|
}
|
|
|
|
/** 从 JSON 反序列化 */
|
|
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
|
|
return MindMapNode.fromJson(json, mapper);
|
|
}
|
|
}
|