feat(复习):标准思维导图生成与回忆对比
- 新增 review_standard_mind_maps / review_recall_records 数据表 - BuiltinMindMapGenerator:从报告/残片/应用场景规则生成标准导图 - MindMapAiClient 接口 + RemoteAiMindMapClient 预留 - StandardMindMapService:生成/编辑/重新生成/回忆对比 - 对比算法:标题归一化 + Bigram Jaccard 模糊匹配 - ReviewController 新增 6 个端点 - MindMapTreeTool / MindMapNode / CompareResult 工具类 - 完整单元测试(10 个,全部通过) - 更新 docs/review-module-design.md
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/** 序列化整棵树为 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user