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:
2026-07-03 08:53:51 +08:00
parent 5f3f35c53b
commit 32b247526b
34 changed files with 1890 additions and 153 deletions
@@ -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);
}
}