# LPT 复习流程设计文档 > 本文档描述 LPT 系统中「回忆复习」(Recall Review)功能的完整设计,包括数据模型、API、前端交互流程和三种入口的处理方式。 --- ## 1. 核心概念 ### 1.1 标准思维导图(Standard Mind Map) - **任务级单一知识树**:每个学习任务有且仅有一棵标准思维导图,存储在 `review_standard_mind_maps` 表 - **生成方式**: - `BUILTIN`:内置规则引擎,按 session 分组、去重、合并 - `AI`:调用 lpt-ai 服务生成缩进大纲后解析 - `USER`:用户手动编辑 - `USER_MERGE`:增量合并后(保留用户编辑 + 追加 AI 新节点) - **数据结构**:`content` 字段存储 JSON 树(递归 `MindMapNode`),`outline` 字段存储缩进大纲文本 - **节点模型**: ``` MindMapNode { title: string // 节点标题 notes?: string // 备注 / 对比标记(MATCHED| / MISSED|) sourceType?: string // REPORT | FRAGMENT | APPLICATION sourceId?: number // 对应源数据主键 children?: MindMapNode[] } ``` - **节点标记约定**(用于对比着色): - `notes` 以 `MATCHED|` 开头 → 绿色(回忆命中) - `notes` 以 `MISSED|` 开头 → 红色(回忆遗漏) - 无标记 → 默认色(未参与对比) ### 1.2 回忆对比记录(Recall Record) - 每次用户提交回忆 → 生成一条 `review_recall_records` 记录 - 核心字段: - `task_num`:关联任务 - `standard_map_id`:对比时使用的标准导图版本 - `focus_path`:复习起点节点路径(`"根 / 分支A / 子节点B"`),null 为全量 - `recall_content`:用户回忆大纲文本 - `compare_result`:对比结果 JSON(含 `matchedTree`、`extraNodes`、覆盖率等) - `recall_ratio` / `matched_count` / `missed_count` / `extra_count`:统计摘要 --- ## 2. 复习流程 ### 2.1 核心逻辑 ``` 1. 确定复习起点(节点路径) 2. 获取标准导图 3. 按起点提取子树作为对比基准 4. 用户在大纲编辑器中回忆填充 5. 提交对比(AI 语义匹配 或 BUILTIN 字符串匹配) 6. 展示对比结果(着色 + 统计) ``` ### 2.2 节点选择 - 用户在标准导图上点击节点 → `MindMapViewer` 发射 `node-select` 事件(含 `title`、`path`、`childCount`) - 弹窗确认:「以「节点名」为起点,复习其下 N 个子知识点?」 - 确认后: - `focusPath` = 选中节点的路径(如 `"Java基础 / 集合 / HashMap"`) - `recallTree` 根节点标题 = 选中节点标题 - 回忆导图以该标题为根,用户补充子节点 ### 2.3 对比范围 - **有 `focusPath`**:`MindMapTreeTool.extractSubtree(root, focusPath)` 提取子树,对比基准 = 子树 - **无 `focusPath`**:对比基准 = 整棵标准导图 - AI 对比:发给 lpt-ai 的 outline 只包含子树范围 - BUILTIN 对比:`compareTrees()` 在子树范围内做标题匹配 ### 2.4 对比算法 **AI 语义匹配(优先):** ``` POST /ai/tasks → type: "compare-recall" → 异步轮询结果 → 返回 { matches: [{standardTitle, recallTitle}], missedTitles, extraNodes, evaluation } → Java 端 buildCompareResultFromAI() 标注标准树 ``` **BUILTIN 字符串匹配(降级):** - 展平标准树和回忆树 - 标题标准化(去标点、去空格、转小写) - 精确匹配 → 模糊匹配(bigram Jaccard ≥ 0.6) - 标记 MATCHED / MISSED / 额外 ### 2.5 结果展示 - 标准导图面板直接显示着色对比结果(不再有独立对比面板) - 绿色(`#c8e6c9`)= 命中,红色(`#ffcdd2`)= 遗漏 - 统计卡片:覆盖率、命中/遗漏/额外计数 - 遗漏项列表 + 额外项列表(可点击回看原文) --- ## 3. 三个复习入口 ### 入口 A:复习总览 → "回忆复习" **路径:** `Welcome.vue` → "复习回看" → `Review.vue`(任务列表)→ 每行"回忆复习"按钮 **前端代码:** `Review.vue:110` ```typescript router.push(`/review/recall/${row.taskNum}`) ``` **流程:** 1. 跳转到 `ReviewRecall.vue`,URL 为 `/review/recall/:taskNum` 2. 页面加载 → `loadData()` 获取标准导图 3. 标准导图 `selectable=true`,显示提示「点击导图节点选择复习起点」 4. 用户点击节点 → 弹窗确认 → 进入回忆模式 5. 回忆填充 → 提交对比 → 展示结果 **相关文件:** - `lpt-fe/src/components/Review.vue` - `lpt-fe/src/components/ReviewRecall.vue` ### 入口 B:从某个残片/报告详情 → "回忆复习" **路径:** `Welcome.vue` 滚动回顾 → 点击卡片 → `ReviewDetail.vue` → "回忆复习"按钮 **前端代码:** `ReviewDetail.vue` 的 `goToRecall()` ```typescript const res = await findNode(taskNum, content.substring(0, 500)); if (res?.data?.path) { router.push(`/review/recall/${taskNum}?focusPath=${encodeURIComponent(res.data.path)}`); } ``` **流程:** 1. 用户在详情页查看某个残片/报告的内容 2. 点击"回忆复习" → 前端调 `POST /review/standard-mind-map/{taskNum}/find-node` 3. 后端 `findClosestNode()` 用 bigram Jaccard 匹配最接近的节点 4. 返回 `{ path, nodeTitle, score }` 5. 跳转到 `/review/recall/:taskNum?focusPath=...` 6. `ReviewRecall.vue` 检测到 URL 参数 → 跳过节点选择,直接进入回忆模式 **后端 API:** ```http POST /review/standard-mind-map/{taskNum}/find-node Content-Type: application/json { "content": "用户查看的残片/报告文本" } → 200 OK { "code": 200, "data": { "path": "根 / 分支A / 子节点B", "nodeTitle": "子节点B", "score": 0.85 } } ``` **匹配算法(`MindMapTreeTool.findClosestNode`):** 1. 展平标准导图所有节点 2. 对每个节点,计算其 `title` 与输入文本的 bigram Jaccard 相似度 3. `notes` 字段也参与匹配(权重 0.5) 4. 返回得分最高(>0.1)的节点 **相关文件:** - `lpt-fe/src/components/ReviewDetail.vue` - `learning-progress-tracker/.../utils/MindMapTreeTool.java`(`findClosestNode`、`similarityScore`) ### 入口 C:首页欢迎页 → 回忆卡片 → "前往回忆复习" **路径:** `Welcome.vue` → 滚动回顾标签 → 点击弹出回忆对话框 → "前往回忆复习"按钮 **前端代码:** `Welcome.vue` 的 `goToRecallFromCard()` ```typescript const tn = group.taskNum; const res = await findNode(tn, content.substring(0, 500)); if (res?.data?.path) { router.push(`/review/recall/${tn}?focusPath=${encodeURIComponent(res.data.path)}`); } ``` **流程:** 1. Welcome 页加载 `GET /review/feed` 获取复习 feed 2. 按 session 分组为标签卡片 3. 用户点击某个标签 → 弹出回忆对话框(先回忆→展开对照) 4. 点击"前往回忆复习" → 同入口 B,先调 findNode 再跳转 **相关文件:** - `lpt-fe/src/components/Welcome.vue` --- ## 4. 关键 API 清单 ### 4.1 标准思维导图 | 方法 | 路径 | 说明 | |------|------|------| | `GET` | `/review/standard-mind-map/{taskNum}` | 获取/自动生成标准导图 | | `POST` | `/review/standard-mind-map/{taskNum}/regenerate?mode=full\|incremental` | 重新生成 | | `PUT` | `/review/standard-mind-map/{taskNum}` | 用户编辑(大纲文本) | | `POST` | `/review/standard-mind-map/{taskNum}/recall` | 提交回忆对比 | | `POST` | `/review/standard-mind-map/{taskNum}/find-node` | 匹配最近节点 | | `GET` | `/review/standard-mind-map/{taskNum}/recall-records` | 历史回忆记录 | ### 4.2 回忆对比请求体 ```json { "recallOutline": "用户回忆的缩进大纲文本\n - 子节点1\n - 子节点2", "focusPath": "根标题 / 分支A / 子节点B" } ``` - `recallOutline`:必填,用户回忆的缩进大纲 - `focusPath`:可选,复习起点节点路径。null = 全量,提供时对比范围为该子树 ### 4.3 对比结果结构(CompareResult) ```json { "matchedTree": { "title": "根节点", "notes": "", "children": [ { "title": "命中的知识点", "notes": "MATCHED|完整原文内容", "sourceType": "REPORT", "sourceId": 1, "children": [] }, { "title": "遗漏的知识点", "notes": "MISSED|完整原文内容", "sourceType": "FRAGMENT", "sourceId": 2, "children": [] } ] }, "extraNodes": [ { "title": "用户额外回忆的知识", "path": "/额外/..." } ], "recallRatio": 0.75, "matchedCount": 3, "missedCount": 1, "extraCount": 1, "evaluation": "AI 评价文本(仅 AI 对比时有值)" } ``` --- ## 5. 前端组件结构 ``` ReviewRecall.vue(主页面) ├── Page header(返回、标题、历史记录按钮) ├── 复习起点指示(focusPath 显示 + 重选按钮) ├── 节点选择确认弹窗(el-dialog) ├── 统计卡片(覆盖率/命中/遗漏/额外) ├── 回忆历史列表 ├── 回忆输入(editable MindMapViewer) ├── 标准导图 + 对比结果(color-by-compare MindMapViewer) │ ├── 未选择起点时 → selectable 模式 │ ├── 已选择起点/已对比 → 着色展示 │ └── 编辑模式 → editable 模式 └── 遗漏项 + 额外项详情列表 ``` ### 5.1 MindMapViewer 组件 Props | 属性 | 类型 | 默认 | 说明 | |------|------|------|------| | `tree` | `MindMapTreeNode \| null` | — | 树数据 | | `editable` | `boolean` | `false` | 是否可编辑 | | `colorByCompare` | `boolean` | `false` | 按 MATCHED/MISSED 着色 | | `height` | `string` | `'480px'` | 画布高度 | | `selectable` | `boolean` | `false` | 节点单击可选择 | | `selectedPath` | `string` | `''` | 当前选中节点路径 | ### 5.2 MindMapViewer 事件 | 事件 | 载荷 | 触发时机 | |------|------|----------| | `change` | `outline: string` | 编辑模式下结构变化 | | `node-click` | `{ sourceType, sourceId }` | 点击带溯源标签的节点 | | `node-select` | `{ title, path, childCount }` | selectable 模式下点击节点 | ### 5.3 MindMapViewer 暴露方法 ```typescript defineExpose({ toOutline }) // toOutline() → 缩进大纲文本 ``` --- ## 6. 后端核心工具方法 ### `MindMapTreeTool.java` | 方法 | 说明 | |------|------| | `extractSubtree(root, path)` | 按 "/" 分隔路径提取子树,返回深拷贝 | | `findClosestNode(root, content)` | bigram Jaccard 匹配最近节点 | | `getPath(root, targetTitle)` | 获取节点到根的路径字符串 | | `similarityScore(a, b)` | 两段文本的 bigram Jaccard 相似度 | | `mergeTrees(oldRoot, newRoot)` | 按标准化标题合并新旧树 | | `toJson(root, mapper)` | 序列化为 JSON | | `fromJson(json, mapper)` | 从 JSON 反序列化 | | `toOutline(root)` | 树 → 缩进大纲 | | `toFullOutline(root)` | 树 → 完整大纲(含根标题) | | `parseOutline(text)` | 缩进大纲 → 树 | | `flatten(root)` | 前序遍历展平 | | `countNodes(root)` | 节点总数 | | `maxDepth(root)` | 最大深度 | ### `StandardMindMapServiceImpl.java` | 方法 | 说明 | |------|------| | `getOrGenerate(taskNum)` | 获取/自动生成标准导图 | | `regenerate(taskNum)` | 全量重新生成(防并发) | | `incrementalGenerate(taskNum)` | 增量合并(保留用户编辑) | | `updateByOutline(taskNum, outline)` | 用户编辑 | | `recallCompare(taskNum, outline, focusPath)` | 回忆对比 | | `findNode(taskNum, content)` | 查找最近节点 | | `listRecallRecords(taskNum)` | 历史记录 | | `getRecallRecord(id)` | 单条记录详情 | --- ## 7. 关键文件索引 ### 前端(`lpt-fe/src/`) | 文件 | 说明 | |------|------| | `components/ReviewRecall.vue` | 回忆复习主页面 | | `components/Review.vue` | 复习总览(任务列表+入口) | | `components/ReviewDetail.vue` | 残片/报告详情页 | | `components/Welcome.vue` | 首页(内含回忆卡片入口) | | `components/MindMapViewer.vue` | 思维导图渲染/编辑/选择组件 | | `api/standardMindMap.ts` | 标准导图 API 封装 | | `api/review.ts` | 复习 feed API | | `api/studySessions.ts` | 学习会话 API | ### 后端(`learning-progress-tracker/src/main/java/.../`) | 文件 | 说明 | |------|------| | `controller/ReviewController.java` | 复习相关端点 | | `service/StandardMindMapService.java` | 标准导图服务接口 | | `service/impl/StandardMindMapServiceImpl.java` | 服务实现 | | `service/impl/BuiltinMindMapGenerator.java` | 内置规则生成器 | | `service/impl/RemoteAiMindMapClient.java` | AI 导图客户端 | | `service/impl/AiServiceClient.java` | lpt-ai 通用客户端 | | `utils/MindMapNode.java` | 树节点 DTO | | `utils/MindMapTreeTool.java` | 树操作工具类 | | `utils/CompareResult.java` | 对比结果 DTO | | `entity/ReviewStandardMindMapEntity.java` | 标准导图实体 | | `entity/ReviewRecallRecordEntity.java` | 回忆记录实体 | | `db/migration/V20260706_1__add_focus_path_to_recall_records.sql` | focus_path 迁移 | ### AI 服务(`lpt-ai/src/`) | 文件 | 说明 | |------|------| | `routes/ai.ts` | AI 任务提交/轮询/抓取标题 | | `task-queue.ts` | 异步任务队列 | | `llm/prompts.ts` | prompt 模板(generate-mind-map / compare-recall / aggregate-report) | | `llm/client.ts` | LLM API 客户端 | | `admin/routes.ts` | 管理面板 | --- ## 8. 数据流图 ``` 学习碎片/报告 ↓ (按 session 分组) BuiltinMindMapGenerator / RemoteAiMindMapClient ↓ 标准思维导图(review_standard_mind_maps) ↓ (用户选择节点) extractSubtree(root, focusPath) ↓ 子树(对比基准) ↓ AI / BUILTIN 对比 ← 用户回忆大纲 ↓ CompareResult ↓ (序列化存储 + 前端渲染) review_recall_records + 着色 MindMapViewer ``` --- ## 9. 历史版本记录 | Commit | 说明 | |--------|------| | `a636e51` | 新增 sessionNum 追踪 + 维度选择(已回退) | | `e5adb40` | 前端会话选择器(已回退) | | `3c3f682` | 防并发生成 + `mergeTrees` + 增量合 | | `5a5a294` | 前端防抖/生成提示/移除冗余面板 | | `84fe452` | **节点选择交互 + focusPath + findNode(当前)** | | `b18f8b4` | **后端 focusPath + extractSubtree + 回退 session(当前)** | | `ebbbeb4` | fix: Array.map 多参数 bug | --- ## 10. 待办/已知问题 - [ ] AI 生成的导图(`RemoteAiMindMapClient`)因为没有 `sourceId`/`sourceType`,`node-click` 溯源不可用 - [ ] `selectable` 模式下,点击无子节点的叶子节点,`childCount` = 0 的确认弹窗体验略奇怪 - [ ] `findClosestNode` 的 bigram 相似度对短文本效果有限,可能需要 `notes` 加权优化 - [ ] 欢迎页(Welcome.vue)的回忆卡片入口「前往回忆复习」按钮文字和位置可以优化