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
+164
View File
@@ -0,0 +1,164 @@
# 复习模块设计说明
本文档是对复习功能当前实现边界的工程说明,不修改原始需求文档。
## 核心理解
复习模块包含两类不同层级的体验:碎片化提醒和围绕思维导图的主动回忆。二者都属于复习模块,但不是同一个业务流程。
## 碎片化提醒
碎片化提醒展示用户自己在学习过程中写下的学习报告和学习残片。
这些内容不是完整复习记录,而是日常滚动出现的记忆触发物。用户看到一段自己写过的内容时,如果能立刻回想起上下文,说明相关知识暂时不需要深入复习;如果想不起来,可以点击进入详情页回看,形成一次轻量提醒。
当前对应能力:
- `GET /review/feed`
- `GET /review/task/{taskNum}`
- `GET /review/report/{id}`
- `GET /review/fragment/{id}`
- 前端 `Review.vue`
- 前端 `ReviewDetail.vue`
## 当前阶段边界
当前阶段暂不追求完整的自动化复习分析,因为项目尚未引入 AI 来处理学习碎片和导图数据。
当前阶段复习模块只需要保留思维导图的基本结构能力:
- 保存任务对应的思维导图
- 支持用户上传外部导图文件
- 解析导图的基础节点结构
- 允许用户在碎片化提醒中回看学习报告和学习残片
这个阶段不应该强制用户逐条处理大量学习碎片。学习碎片很多时,逐条关联和对照会让复习过程变得漫长、枯燥,并违背复习模块“简单、不抵触、可随意围绕某个知识点复习”的初衷。
## 思维导图的意义
思维导图不是普通附件,也不是单纯的存储对象。它是复习模块的主要交互形式。
用户复习时,主要动作应该是围绕某个知识点进行回忆,并编写或重绘思维导图。导图的意义在于降低复习阻力:用户不需要按顺序处理所有碎片,而是可以从任意知识点出发,自由地把自己能想起来的内容组织成结构。
初版设计中已经提到:
- 后续复习时可以在脑子里重绘那张图
- 没有想起来的部分就是需要重新看的地方
- 如果顺利绘制出了好的思维导图,就替换原来的思维导图
- 如果能顺利用思维导图描述所学内容,复习效果就能体现在这个过程中
因此,导图应该是复习效果的表达方式,而不仅是文件存储。
## 应用场景的位置
应用场景不应归属复习模块。
应用场景更接近学习任务的最终目标:完成整个学习任务后,用户希望能做什么、产出什么、应用到哪里。它应该挂在学习任务模块下,而不是挂在复习流程或某条学习碎片下。
当前实现已将其迁移为任务级能力,由任务页面维护,并使用 `task_applications` 存储。
## 模块边界
学习执行模块产生学习报告和学习残片。
复习模块使用这些产物做两件事:
- 在碎片化提醒中滚动展示它们
- 在引入 AI 后,把它们整理进程序生成的知识网络
复习模块不要求用户手动逐条整理学习报告和学习残片。学习报告/残片是后续自动整理和对照分析的数据来源。
## 下一阶段设计:AI 生成导图与用户导图对比
下一阶段引入 AI 后,复习模块的目标应升级为:
1. 程序读取学习报告、学习残片、已有导图等数据。
2. 程序自动将碎片整理成一个具有关联关系的巨大思维导图。
3. 用户围绕某个知识点进行回忆,并绘制自己的导图。
4. 系统将用户导图与程序生成导图进行结构对比。
5. 系统定位用户忽略、遗漏、误解或尚未建立关联的内容。
6. 用户根据差异回看必要的学习报告或残片,而不是从头处理所有碎片。
这个设计可以解决当前手动复习流程的两个问题:
- 学习碎片很多时,不需要用户逐条筛选,避免复习过程漫长且枯燥。
- 思维导图不再只是存储对象,而是成为用户回忆结果和程序知识网络之间的对照媒介。
## 对初版设计的补充判断
初版设计没有忽略“思维导图用于复习”的方向,反而已经明确指出导图应承担脑内重绘、发现遗漏、体现复习效果的作用。
初版设计没有具体描述“AI 自动整理碎片为巨大导图,并与用户导图进行对比”的实现逻辑。这是当前讨论对初版设计的重要补充,适合放入下一阶段实现。
初版中提到”复习内容可以应用到什么地方”,但结合当前理解,应用场景应从复习模块中移出,归入学习任务的最终目标或验收目标。
## 第二阶段实现:标准思维导图与回忆对比(2026-07-03)
第二阶段将 AI 生成导图与用户导图对比的设计落地,引入以下能力:
### 核心概念
- **标准思维导图(Standard Mind Map)**:每个学习任务对应一份”标准”导图,由系统从该任务的已有学习报告、学习残片、应用场景中提取整理而成。它是用户回忆的对照基准,也可由用户手动编辑修正。
- **回忆对比(Recall Review)**:用户凭记忆以缩进大纲格式写下对该任务知识点的回忆,系统将其与标准导图进行树结构对比,定位已掌握、遗漏和额外回忆的内容。
### 内置生成器(BuiltinMindMapGenerator
当前阶段未接入外部 AI,由内置规则引擎生成标准导图:
- **根节点**:任务名称
- **一级分支**:按学习会话分组,以”日期 + 报告前60字摘要”为标题
- **子分支**:该会话下的每条报告、每条残片成为一个子节点
- **应用场景分支**:如有已创建的应用场景,附加为独立的”应用场景”分支
- **去重**:同级节点按标准化标题对比并合并
- **追溯**:每个节点携带 `sourceType`REPORT/FRAGMENT/APPLICATION)和 `sourceId`,前端可点击回看原文
### AI 客户端抽象(MindMapAiClient
预留了远程 AI 调用接口,供后续接入真实 AI 使用:
- 配置项 `lpt.ai.endpoint` / `lpt.ai.api-key`(当前均未配置)
- 未配置时自动回退到内置生成器
- 接口返回 `MindMapNode` 树结构,与内置生成器输出格式一致
### 回忆对比算法(StandardMindMapServiceImpl.compareTrees
对比标准导图与用户回忆大纲的树结构:
1. **展平**:前序遍历将两颗树分别展开为节点列表
2. **精确匹配**:对每个标准节点标题做归一化(去空格标点 + 小写),在回忆节点中查找完全匹配
3. **模糊匹配**:未精确匹配时,计算字符 Bigram Jaccard 相似度(阈值 0.6),容忍中文同义表达
4. **标注**:匹配的节点标注 `MATCHED`,遗漏的节点标注 `MISSED`,用户多写的内容归为 `EXTRA`
5. **统计**:计算回忆覆盖率 `recall_ratio = matched / (matched + missed)`
### 用户交互
- **回忆复习页**`/review/recall/:taskNum`):双栏布局。左栏为回忆大纲输入区(缩进文本)+ 对比结果展示,右栏为标准导图区(默认折叠防剧透)
- **标准导图编辑**:用户可展开查看、编辑大纲文本并保存,编辑后来源标记为 `USER`
- **重新生成**:如需从头生成新版本,可一键重新生成
- **遗漏项回溯**:对比后定位到的遗漏知识点自动列出,点击”查看原文”可直接跳转到对应的报告/残片详情页
- **回忆历史**:查阅历次回忆对比记录,查看覆盖率变化趋势
### 新增数据库表
- `review_standard_mind_maps`:每任务一行,存储标准导图的 JSON 树结构、大纲文本、生成元信息
- `review_recall_records`:存储用户每次回忆对比的原始大纲、对比结果 JSON、覆盖率等统计
未改动既有表的 schema,与原有 `review_mind_maps`(用户手动上传的导图文件)并存。
### 新增端点
| 方法 | 端点 | 说明 |
|------|------|------|
| GET | `/review/standard-mind-map/{taskNum}` | 获取或自动生成标准导图 |
| POST | `/review/standard-mind-map/{taskNum}/regenerate` | 强制重新生成 |
| PUT | `/review/standard-mind-map/{taskNum}` | 用户编辑标准导图 |
| POST | `/review/standard-mind-map/{taskNum}/recall` | 用户提交回忆大纲,返回对比结果 |
| GET | `/review/standard-mind-map/{taskNum}/recall-records` | 回忆对比历史列表 |
| GET | `/review/standard-mind-map/recall-records/{recordId}` | 单条回忆记录详情 |
### 下一步展望
- 接入真实 AI API(配置 `lpt.ai.api-key` 后切换为 AI 生成器,内置生成器作为降级)
- 可视化思维导图渲染(而非纯缩进文本展示)
- 对比结果中显示更精确的路径定位
- AI 驱动的用户导图评分与改进建议
@@ -2,16 +2,19 @@ package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpdateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import com.guo.learningprogresstracker.service.ReviewService;
import com.guo.learningprogresstracker.service.StandardMindMapService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
@@ -21,6 +24,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.List;
/**
* 复习模块控制层 — 复习内容滚动展示与交互
@@ -33,6 +37,8 @@ public class ReviewController {
private final ReviewService reviewService;
private final StandardMindMapService standardMindMapService;
/**
* 获取复习 feed,合并报告和残片按时间倒序
*/
@@ -83,42 +89,6 @@ public class ReviewController {
return CommonResult.success(reviewService.getFragmentDetail(id));
}
/**
* 获取指定任务的应用场景列表
*/
@GetMapping("/applications/{taskNum}")
public CommonResult<List<ReviewApplicationEntity>> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(reviewService.getApplications(taskNum));
}
/**
* 创建应用场景
*/
@PostMapping("/applications")
public CommonResult<ReviewApplicationEntity> createApplication(
@Valid @RequestBody CreateReviewApplicationRequest request) throws NotFindEntitiesException {
return CommonResult.success(reviewService.createApplication(request));
}
/**
* 更新应用场景
*/
@PutMapping("/applications/{id}")
public CommonResult<ReviewApplicationEntity> updateApplication(
@PathVariable Integer id,
@Valid @RequestBody UpdateReviewApplicationRequest request) throws NotFindEntitiesException {
return CommonResult.success(reviewService.updateApplication(id, request));
}
/**
* 删除应用场景
*/
@DeleteMapping("/applications/{id}")
public CommonResult<Void> deleteApplication(@PathVariable Integer id) throws NotFindEntitiesException {
reviewService.deleteApplication(id);
return CommonResult.success();
}
/**
* 获取指定任务的思维导图
*/
@@ -146,4 +116,62 @@ public class ReviewController {
@RequestParam("file") MultipartFile file) throws NotFindEntitiesException, IOException {
return CommonResult.success(reviewService.uploadMindMap(taskNum, file));
}
// ============ 标准思维导图与回忆对比 ============
/**
* 获取或自动生成任务的标准思维导图
*/
@GetMapping("/standard-mind-map/{taskNum}")
public CommonResult<ReviewStandardMindMapEntity> getStandardMindMap(
@PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.getOrGenerate(taskNum));
}
/**
* 强制重新生成任务的标准思维导图
*/
@PostMapping("/standard-mind-map/{taskNum}/regenerate")
public CommonResult<ReviewStandardMindMapEntity> regenerateStandardMindMap(
@PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.regenerate(taskNum));
}
/**
* 用户编辑标准思维导图(大纲文本形式)
*/
@PutMapping("/standard-mind-map/{taskNum}")
public CommonResult<ReviewStandardMindMapEntity> updateStandardMindMap(
@PathVariable String taskNum,
@Valid @RequestBody UpdateStandardMindMapRequest request) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.updateByOutline(taskNum, request.getOutline()));
}
/**
* 用户提交回忆大纲,与标准导图对比
*/
@PostMapping("/standard-mind-map/{taskNum}/recall")
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
@PathVariable String taskNum,
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline()));
}
/**
* 获取该任务的所有回忆对比记录
*/
@GetMapping("/standard-mind-map/{taskNum}/recall-records")
public CommonResult<List<ReviewRecallRecordEntity>> listRecallRecords(
@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.listRecallRecords(taskNum));
}
/**
* 获取单条回忆对比记录详情
*/
@GetMapping("/standard-mind-map/recall-records/{recordId}")
public CommonResult<ReviewRecallRecordEntity> getRecallRecord(
@PathVariable Integer recordId) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.getRecallRecord(recordId));
}
}
@@ -4,15 +4,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.common.Ops;
import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
@@ -27,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.rmi.ServerException;
import java.util.List;
/**
* 任务控制层
@@ -69,6 +74,36 @@ public class TaskController {
return CommonResult.success();
}
@GetMapping("/{taskNum}/applications")
@Operation(summary = "获取任务应用场景列表")
public CommonResult<List<TaskApplicationEntity>> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(tasksService.getApplications(taskNum));
}
@PostMapping("/{taskNum}/applications")
@Operation(summary = "创建任务应用场景")
public CommonResult<TaskApplicationEntity> createApplication(
@PathVariable String taskNum,
@Valid @RequestBody CreateTaskApplicationRequest request) throws NotFindEntitiesException {
request.setTaskNum(taskNum);
return CommonResult.success(tasksService.createApplication(request));
}
@PutMapping("/applications/{id}")
@Operation(summary = "更新任务应用场景")
public CommonResult<TaskApplicationEntity> updateApplication(
@PathVariable Integer id,
@Valid @RequestBody UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
return CommonResult.success(tasksService.updateApplication(id, request));
}
@DeleteMapping("/applications/{id}")
@Operation(summary = "删除任务应用场景")
public CommonResult<Void> deleteApplication(@PathVariable Integer id) throws NotFindEntitiesException {
tasksService.deleteApplication(id);
return CommonResult.success();
}
@GetMapping
@Operation(summary = "获取所有任务列表")
public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@@ -4,9 +4,8 @@ import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CreateReviewApplicationRequest {
public class CreateTaskApplicationRequest {
@NotBlank(message = "任务编号不可为空")
private String taskNum;
@NotBlank(message = "应用项目标题不可为空")
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 用户回忆对比请求
*/
@Data
public class RecallCompareRequest {
@NotBlank(message = "回忆大纲不可为空")
private String recallOutline;
}
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 更新标准思维导图(大纲文本形式)请求
*/
@Data
public class UpdateStandardMindMapRequest {
@NotBlank(message = "思维导图大纲不可为空")
private String outline;
}
@@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateReviewApplicationRequest {
public class UpdateTaskApplicationRequest {
@NotBlank(message = "应用项目标题不可为空")
private String title;
@@ -0,0 +1,56 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* 复习回忆与标准导图的对比记录
*/
@TableName(value = "review_recall_records")
@Data
public class ReviewRecallRecordEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "task_num")
private String taskNum;
@TableField(value = "standard_map_id")
private Integer standardMapId;
/**
* 用户回忆绘制的导图大纲文本
*/
@TableField(value = "recall_content")
private String recallContent;
/**
* 与标准导图的结构对比结果 JSON
*/
@TableField(value = "compare_result")
private String compareResult;
/**
* 回忆覆盖率(0-1
*/
@TableField(value = "recall_ratio")
private Double recallRatio;
@TableField(value = "matched_count")
private Integer matchedCount;
@TableField(value = "missed_count")
private Integer missedCount;
@TableField(value = "extra_count")
private Integer extraCount;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -0,0 +1,63 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 任务标准思维导图:由内置规则或 AI 从学习报告/残片生成,用户可修改
*/
@TableName(value = "review_standard_mind_maps")
@Data
public class ReviewStandardMindMapEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "task_num")
private String taskNum;
@TableField(value = "title")
private String title;
/**
* 标准导图统一树结构 JSON(根节点:{title, notes, children}
*/
@TableField(value = "content")
private String content;
/**
* 缩进大纲文本,供前端展示与用户编辑
*/
@TableField(value = "outline")
private String outline;
@TableField(value = "summary")
private String summary;
/**
* 生成来源:BUILTIN/AI/USER
*/
@TableField(value = "generator")
private String generator;
@TableField(value = "generator_version")
private String generatorVersion;
@TableField(value = "source_report_count")
private Integer sourceReportCount;
@TableField(value = "source_fragment_count")
private Integer sourceFragmentCount;
@TableField(value = "generated_time")
private LocalDateTime generatedTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -8,9 +8,9 @@ import lombok.Data;
import java.io.Serializable;
@TableName(value = "review_applications")
@TableName(value = "task_applications")
@Data
public class ReviewApplicationEntity extends BaseEntity implements Serializable {
public class TaskApplicationEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@@ -6,7 +6,7 @@ import java.util.Arrays;
import java.util.Locale;
@AllArgsConstructor
public enum ReviewApplicationStatusEnum {
public enum TaskApplicationStatusEnum {
TODO("TODO", "待应用"),
DOING("DOING", "应用中"),
DONE("DONE", "已完成");
@@ -22,7 +22,7 @@ public enum ReviewApplicationStatusEnum {
return description;
}
public static ReviewApplicationStatusEnum fromCodeOrDefault(String code) {
public static TaskApplicationStatusEnum fromCodeOrDefault(String code) {
if (code == null || code.trim().isEmpty()) {
return TODO;
}
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewRecallRecordMapper extends BaseMapper<ReviewRecallRecordEntity> {
}
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewStandardMindMapMapper extends BaseMapper<ReviewStandardMindMapEntity> {
}
@@ -1,9 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewApplicationMapper extends BaseMapper<ReviewApplicationEntity> {
public interface TaskApplicationMapper extends BaseMapper<TaskApplicationEntity> {
}
@@ -0,0 +1,44 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.utils.MindMapNode;
import java.util.List;
import java.util.Optional;
/**
* 思维导图 AI 生成客户端抽象。
* <p>后续接入真实 AI 时实现此接口,通过 Spring {@code @Profile} / {@code @ConditionalOnProperty} 切换。
* 当前由内置规则生成器 {@code BuiltinMindMapGenerator} 充当。</p>
*/
public interface MindMapAiClient {
/**
* 是否可用(例如:API Key 已配置)
*/
boolean isAvailable();
/**
* 根据学习数据生成标准思维导图根节点
*
* @param task 学习任务
* @param reports 该任务的全部学习报告
* @param fragments 该任务的全部学习残片
* @param applications 该任务的应用场景(可选)
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
*/
Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint);
/**
* 获取生成器标识(如 {@code BUILTIN} / {@code AI-4.5}
*/
String generatorName();
}
@@ -2,10 +2,7 @@ package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpdateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
@@ -51,26 +48,6 @@ public interface ReviewService {
*/
StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException;
/**
* 获取指定任务的应用场景列表
*/
List<ReviewApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException;
/**
* 创建应用场景
*/
ReviewApplicationEntity createApplication(CreateReviewApplicationRequest request) throws NotFindEntitiesException;
/**
* 更新应用场景
*/
ReviewApplicationEntity updateApplication(Integer id, UpdateReviewApplicationRequest request) throws NotFindEntitiesException;
/**
* 删除应用场景
*/
void deleteApplication(Integer id) throws NotFindEntitiesException;
/**
* 获取指定任务的思维导图
*/
@@ -0,0 +1,48 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import java.util.List;
/**
* 标准思维导图服务。
* <p>负责标准导图的按需生成、查询、用户编辑,以及用户回忆导图的对比。</p>
*/
public interface StandardMindMapService {
/**
* 获取指定任务的标准思维导图,不存在时自动生成。
*/
ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 强制重新生成指定任务的标准思维导图。
*/
ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 用户手动编辑标准思维导图(用缩进大纲文本替换)。
*/
ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException;
/**
* 用户提交回忆大纲,与标准导图对比并返回结果。
*
* @param recallOutline 用户回忆的缩进大纲文本
* @return 对比记录实体(compareResult 字段包含对比树 JSON
*/
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline) throws NotFindEntitiesException, OperationFailedException;
/**
* 获取该任务的所有回忆对比记录。
*/
List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException;
/**
* 获取单条对比记录详情。
*/
ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException;
}
@@ -3,12 +3,17 @@ package com.guo.learningprogresstracker.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import java.rmi.ServerException;
import java.util.List;
/**
* @author guo
@@ -26,4 +31,12 @@ public interface TasksService extends IService<TaskEntity> {
void updateTask(String taskId, TaskRequest updatedTask);
void deleteTask(String taskId) throws ServerException;
List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException;
TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException;
TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException;
void deleteApplication(Integer id) throws NotFindEntitiesException;
}
@@ -0,0 +1,181 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。
* <p>规则策略:</p>
* <ol>
* <li>根节点 = 任务名称</li>
* <li>一级分支 = 按会话(日期+报告摘要)分组</li>
* <li>二级分支 = 该会话下的残片标题</li>
* <li>附加分支"应用场景" = 任务应用场景(如有)</li>
* <li>去重:标准化后标题对比,合并内容相似的节点</li>
* <li>引用追溯:每个节点携带 sourceType / sourceId</li>
* </ol>
*/
@Slf4j
@Component
public class BuiltinMindMapGenerator implements MindMapAiClient {
private static final int MAX_TITLE_LENGTH = 60;
@Override
public boolean isAvailable() {
return true; // 始终可用
}
@Override
public String generatorName() {
return "BUILTIN";
}
@Override
public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint) {
if (reports.isEmpty() && fragments.isEmpty()) {
return Optional.empty();
}
MindMapNode root = new MindMapNode(task.getTaskName() != null ? task.getTaskName() : "学习任务");
root.setNotes(task.getTaskDescription() != null ? task.getTaskDescription() : "");
// 按 session 分组
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
Map<String, List<StudyReportFragmentsEntity>> fragmentsBySession = fragments.stream()
.collect(Collectors.groupingBy(StudyReportFragmentsEntity::getSessionNum));
// 合并所有 session
Set<String> allSessions = new LinkedHashSet<>();
allSessions.addAll(reportsBySession.keySet());
allSessions.addAll(fragmentsBySession.keySet());
for (String sessionNum : allSessions) {
List<StudyReportsEntity> sessReports = reportsBySession.getOrDefault(sessionNum, List.of());
List<StudyReportFragmentsEntity> sessFragments = fragmentsBySession.getOrDefault(sessionNum, List.of());
// 会话分支标题:取第一条报告的前 60 字作为摘要,或直接写"学习记录"
String sessionTitle;
if (!sessReports.isEmpty()) {
String firstReport = sessReports.get(0).getContent();
sessionTitle = truncate(firstReport, MAX_TITLE_LENGTH);
if (sessReports.get(0).getCreatedTime() != null) {
sessionTitle = formatDate(sessReports.get(0).getCreatedTime()) + " " + sessionTitle;
}
} else {
sessionTitle = "学习记录 " + (sessFragments.isEmpty() ? "" : formatDate(sessFragments.get(0).getCreatedTime()));
}
MindMapNode sessionNode = new MindMapNode(sessionTitle);
// 报告作为子节点
for (StudyReportsEntity report : sessReports) {
String content = report.getContent();
if (content == null || content.isBlank()) continue;
MindMapNode reportNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
reportNode.setNotes(content);
reportNode.setSourceType("REPORT");
reportNode.setSourceId(report.getId());
sessionNode.getChildren().add(reportNode);
}
// 残片作为子节点
for (StudyReportFragmentsEntity frag : sessFragments) {
String content = frag.getContent();
if (content == null || content.isBlank()) continue;
MindMapNode fragNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
fragNode.setNotes(content);
fragNode.setSourceType("FRAGMENT");
fragNode.setSourceId(frag.getId());
sessionNode.getChildren().add(fragNode);
}
root.getChildren().add(sessionNode);
}
// 应用场景分支(如有)
if (applications != null && !applications.isEmpty()) {
MindMapNode appNode = new MindMapNode("应用场景");
for (TaskApplicationEntity app : applications) {
if (app.getTitle() == null || app.getTitle().isBlank()) continue;
MindMapNode child = new MindMapNode(app.getTitle());
child.setNotes(app.getDescription() != null ? app.getDescription() : "");
child.setSourceType("APPLICATION");
child.setSourceId(app.getId());
appNode.getChildren().add(child);
}
if (!appNode.getChildren().isEmpty()) {
root.getChildren().add(appNode);
}
}
// 去重:同级 title 标准化后合并
deduplicateChildren(root);
log.info("BuiltinMindMapGenerator: 为任务[{}]生成导图,共 {} 个节点,{} 层",
task.getTaskNum(), MindMapTreeTool.countNodes(root), MindMapTreeTool.maxDepth(root));
return Optional.of(root);
}
/** 同级节点按标准化标题去重 */
private void deduplicateChildren(MindMapNode node) {
if (node.getChildren() == null || node.getChildren().isEmpty()) return;
Map<String, MindMapNode> seen = new LinkedHashMap<>();
for (MindMapNode child : node.getChildren()) {
String key = normalize(child.getTitle());
seen.merge(key, child, (a, b) -> {
// 保留 notes 较长的那个
if (b.getNotes() != null && b.getNotes().length() > (a.getNotes() != null ? a.getNotes().length() : 0)) {
a.setNotes(b.getNotes());
}
// 合并子节点
if (b.getChildren() != null) {
a.getChildren().addAll(b.getChildren());
}
return a;
});
}
node.setChildren(new ArrayList<>(seen.values()));
for (MindMapNode child : node.getChildren()) {
deduplicateChildren(child);
}
}
private static String normalize(String s) {
if (s == null) return "";
return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "")
.toLowerCase(Locale.ROOT)
.trim();
}
private static String truncate(String s, int maxLen) {
if (s == null) return "";
if (s.length() <= maxLen) return s;
return s.substring(0, maxLen) + "";
}
private static String formatDate(LocalDateTime dt) {
if (dt == null) return "";
return dt.format(DateTimeFormatter.ofPattern("MM-dd"));
}
}
@@ -0,0 +1,74 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.MindMapNode;
import jakarta.annotation.PostConstruct;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/**
* 远程 AI 思维导图生成客户端(预留实现)。
* <p>通过 {@code lpt.ai.endpoint} / {@code lpt.ai.api-key} 配置;未配置时不可用,
* 上层服务会回退到 {@link BuiltinMindMapGenerator}。</p>
*/
@Slf4j
@Component
@ConfigurationProperties(prefix = "lpt.ai")
@Setter
public class RemoteAiMindMapClient implements MindMapAiClient {
/** AI 服务端点 */
private String endpoint;
/** API Key */
private String apiKey;
/** 模型名称 */
private String model = "claude-fable-5";
/** 超时秒数 */
private int timeoutSeconds = 60;
private boolean enabled = false;
@PostConstruct
void init() {
enabled = apiKey != null && !apiKey.isBlank() && endpoint != null && !endpoint.isBlank();
if (enabled) {
log.info("RemoteAiMindMapClient 已启用: endpoint={}, model={}", endpoint, model);
} else {
log.info("RemoteAiMindMapClient 未配置,将使用内置生成器");
}
}
@Override
public boolean isAvailable() {
return enabled;
}
@Override
public String generatorName() {
return "AI-" + model;
}
@Override
public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint) {
if (!enabled) {
return Optional.empty();
}
// TODO: 调用远程 AI API,构造 prompt 发送 reports/fragments
// RestClient.create().post().uri(endpoint).body(prompt).retrieve()
log.warn("RemoteAiMindMapClient: 远程 AI 调用尚未实现,请配置 API Key 并实现请求逻辑");
return Optional.empty();
}
}
@@ -4,20 +4,15 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpdateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.ReviewApplicationStatusEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.ReviewApplicationMapper;
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
@@ -60,7 +55,6 @@ public class ReviewServiceImpl implements ReviewService {
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudySessionsMapper studySessionsMapper;
private final TasksMapper tasksMapper;
private final ReviewApplicationMapper reviewApplicationMapper;
private final ReviewMindMapMapper reviewMindMapMapper;
private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper());
@@ -158,48 +152,6 @@ public class ReviewServiceImpl implements ReviewService {
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
}
@Override
public List<ReviewApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return reviewApplicationMapper.selectList(
Wrappers.<ReviewApplicationEntity>lambdaQuery()
.eq(ReviewApplicationEntity::getTaskNum, taskNum)
.orderByDesc(ReviewApplicationEntity::getLastModifiedTime)
.orderByDesc(ReviewApplicationEntity::getCreatedTime));
}
@Override
public ReviewApplicationEntity createApplication(CreateReviewApplicationRequest request) throws NotFindEntitiesException {
ensureTaskExists(request.getTaskNum());
ReviewApplicationEntity entity = new ReviewApplicationEntity();
entity.setTaskNum(request.getTaskNum());
entity.setTitle(request.getTitle());
entity.setDescription(request.getDescription());
entity.setResourceUrl(request.getResourceUrl());
entity.setStatus(normalizeApplicationStatus(request.getStatus()));
reviewApplicationMapper.insert(entity);
return entity;
}
@Override
public ReviewApplicationEntity updateApplication(Integer id, UpdateReviewApplicationRequest request) throws NotFindEntitiesException {
ReviewApplicationEntity existing = Optional.ofNullable(reviewApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
existing.setTitle(request.getTitle());
existing.setDescription(request.getDescription());
existing.setResourceUrl(request.getResourceUrl());
existing.setStatus(normalizeApplicationStatus(request.getStatus()));
reviewApplicationMapper.updateById(existing);
return existing;
}
@Override
public void deleteApplication(Integer id) throws NotFindEntitiesException {
ReviewApplicationEntity existing = Optional.ofNullable(reviewApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
reviewApplicationMapper.deleteById(existing.getId());
}
@Override
public ReviewMindMapEntity getMindMap(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
@@ -381,10 +333,6 @@ public class ReviewServiceImpl implements ReviewService {
}
}
private String normalizeApplicationStatus(String status) {
return ReviewApplicationStatusEnum.fromCodeOrDefault(status).getCode();
}
private String normalizeMindMapFormat(String contentFormat) {
if (!StringUtils.hasText(contentFormat)) {
return DEFAULT_MIND_MAP_FORMAT;
@@ -0,0 +1,359 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.entity.*;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import com.guo.learningprogresstracker.mapper.*;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.service.StandardMindMapService;
import com.guo.learningprogresstracker.utils.CompareResult;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 标准思维导图服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StandardMindMapServiceImpl implements StandardMindMapService {
private final ReviewStandardMindMapMapper standardMindMapMapper;
private final ReviewRecallRecordMapper recallRecordMapper;
private final TasksMapper tasksMapper;
private final StudyReportsMapper studyReportsMapper;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final TaskApplicationMapper taskApplicationMapper;
private final StudySessionsMapper studySessionsMapper;
private final List<MindMapAiClient> aiClients;
private final ObjectMapper objectMapper;
private static final String GENERATOR_BUILTIN = "BUILTIN";
private static final String GENERATOR_USER = "USER";
private static final String MATCH_STATUS_MATCHED = "MATCHED";
private static final String MATCH_STATUS_MISSED = "MISSED";
// ============ 查询与生成 ============
@Override
public ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
return existing != null ? existing : doGenerate(taskNum);
}
@Override
public ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
return doGenerate(taskNum);
}
@Override
@Transactional
public ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
if (existing == null) {
existing = new ReviewStandardMindMapEntity();
existing.setTaskNum(taskNum);
}
// 解析大纲为树节点
MindMapNode root = MindMapTreeTool.parseOutline(outline);
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
existing.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
existing.setContent(contentJson);
existing.setOutline(outline);
existing.setGenerator(GENERATOR_USER);
existing.setGeneratorVersion(null);
existing.setSummary("用户编辑,共 " + MindMapTreeTool.countNodes(root) + " 个节点");
existing.setGeneratedTime(LocalDateTime.now());
if (existing.getId() == null) {
standardMindMapMapper.insert(existing);
} else {
standardMindMapMapper.updateById(existing);
}
return existing;
}
// ============ 回忆对比 ============
@Override
@Transactional
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline)
throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
// 1. 获取标准导图(自动生成)
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
// 2. 解析标准树和用户回忆树
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
// 3. 执行对比
CompareResult result = compareTrees(standardRoot, recallRoot);
// 4. 序列化对比结果
String resultJson;
try {
resultJson = objectMapper.writeValueAsString(result);
} catch (Exception e) {
throw new OperationFailedException("对比结果序列化失败");
}
// 5. 保存回忆记录
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
record.setTaskNum(taskNum);
record.setStandardMapId(standard.getId());
record.setRecallContent(recallOutline);
record.setCompareResult(resultJson);
record.setRecallRatio(result.getRecallRatio());
record.setMatchedCount(result.getMatchedCount());
record.setMissedCount(result.getMissedCount());
record.setExtraCount(result.getExtraCount());
recallRecordMapper.insert(record);
log.info("回忆对比: taskNum={}, recallRatio={}, matched={}, missed={}, extra={}",
taskNum, result.getRecallRatio(), result.getMatchedCount(),
result.getMissedCount(), result.getExtraCount());
return standard;
}
// ============ 对比算法核心 ============
/**
* 两颗树的节点级对比算法。
*/
CompareResult compareTrees(MindMapNode standardRoot, MindMapNode recallRoot) {
CompareResult result = new CompareResult();
// 展平标准树
List<MindMapNode> standardFlat = MindMapTreeTool.flatten(standardRoot);
// 展平回忆树(排除根节点本身)
List<MindMapNode> recallFlat = MindMapTreeTool.flatten(recallRoot).stream()
.filter(n -> n != recallRoot)
.collect(Collectors.toList());
// 构建回忆节点标题 → 节点映射(标准化后)
Map<String, MindMapNode> recallTitleMap = new LinkedHashMap<>();
for (MindMapNode node : recallFlat) {
recallTitleMap.merge(normalize(node.getTitle()), node, (a, b) -> a);
}
// 标注标准树
int matched = 0, missed = 0;
for (MindMapNode node : standardFlat) {
if (node == standardRoot) continue; // 跳过根节点
String key = normalize(node.getTitle());
boolean found = recallTitleMap.containsKey(key);
if (found) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
// 尝试模糊匹配
found = fuzzyMatch(node.getTitle(), recallTitleMap);
if (found) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
node.setNotes(MATCH_STATUS_MISSED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
missed++;
}
}
}
// 找出额外节点(用户在回忆中新增的、标准树中没有的)
Set<String> standardNormTitles = standardFlat.stream()
.map(n -> normalize(n.getTitle()))
.collect(Collectors.toSet());
List<CompareResult.FlatNode> extraNodes = new ArrayList<>();
for (MindMapNode node : recallFlat) {
String key = normalize(node.getTitle());
if (!standardNormTitles.contains(key)) {
CompareResult.FlatNode flat = new CompareResult.FlatNode();
flat.setTitle(node.getTitle());
flat.setPath(node.getTitle()); // 简化路径
extraNodes.add(flat);
}
}
int total = matched + missed;
result.setMatchedTree(standardRoot);
result.setExtraNodes(extraNodes);
result.setMatchedCount(matched);
result.setMissedCount(missed);
result.setExtraCount(extraNodes.size());
result.setRecallRatio(total > 0 ? (double) matched / total : 0);
return result;
}
// ============ 回忆记录查询 ============
@Override
public List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return recallRecordMapper.selectList(
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
.eq(ReviewRecallRecordEntity::getTaskNum, taskNum)
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
}
@Override
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
return Optional.ofNullable(recallRecordMapper.selectById(recordId))
.orElseThrow(() -> new NotFindEntitiesException("回忆记录[" + recordId + "]不存在"));
}
// ============ 内部方法 ============
private ReviewStandardMindMapEntity doGenerate(String taskNum) throws OperationFailedException {
TaskEntity task = tasksMapper.selectOne(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
if (task == null) {
throw new OperationFailedException("任务[" + taskNum + "]不存在");
}
// 收集学习数据
List<String> sessionNums = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getTaskNum, taskNum)
.select(StudySessionsEntity::getSessionNum))
.stream().map(StudySessionsEntity::getSessionNum).collect(Collectors.toList());
List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of()
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.in(StudyReportsEntity::getSessionNum, sessionNums));
List<StudyReportFragmentsEntity> fragments = sessionNums.isEmpty() ? List.of()
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums));
List<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
if (reports.isEmpty() && fragments.isEmpty()) {
throw new OperationFailedException("任务[" + taskNum + "]没有学习报告或残片,无法生成思维导图");
}
// 找可用的 AI 客户端
MindMapAiClient client = aiClients.stream()
.filter(MindMapAiClient::isAvailable)
.findFirst()
.orElse(null);
if (client == null) {
throw new OperationFailedException("没有可用的思维导图生成器");
}
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
if (optRoot.isEmpty()) {
throw new OperationFailedException("思维导图生成失败");
}
MindMapNode root = optRoot.get();
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
String outline = MindMapTreeTool.toFullOutline(root);
int nodeCount = MindMapTreeTool.countNodes(root);
int depth = MindMapTreeTool.maxDepth(root);
ReviewStandardMindMapEntity entity = queryByTaskNum(taskNum);
boolean create = entity == null;
if (create) {
entity = new ReviewStandardMindMapEntity();
entity.setTaskNum(taskNum);
}
entity.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
entity.setContent(contentJson);
entity.setOutline(outline);
entity.setSummary("" + nodeCount + " 个节点,最大层级 " + depth);
entity.setGenerator(client.generatorName());
entity.setGeneratorVersion("1.0");
entity.setSourceReportCount(reports.size());
entity.setSourceFragmentCount(fragments.size());
entity.setGeneratedTime(LocalDateTime.now());
if (create) {
standardMindMapMapper.insert(entity);
} else {
standardMindMapMapper.updateById(entity);
}
return entity;
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
}
}
private ReviewStandardMindMapEntity queryByTaskNum(String taskNum) {
return standardMindMapMapper.selectOne(
Wrappers.<ReviewStandardMindMapEntity>lambdaQuery()
.eq(ReviewStandardMindMapEntity::getTaskNum, taskNum)
.last("LIMIT 1"));
}
// ============ 标题归一化 ============
/**
* 标准化标题用于对比:去空格、去标点、转小写。
*/
static String normalize(String s) {
if (s == null) return "";
// 先移除内部对比标记前缀,再清理标点
String result = s.replaceAll("^(?:MATCHED|MISSED)\\|", "");
result = result.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "");
return result.toLowerCase(Locale.ROOT).trim();
}
/**
* 模糊匹配:计算字符 bigram Jaccard 相似度
*/
static boolean fuzzyMatch(String title, Map<String, MindMapNode> recallTitleMap) {
if (title == null || title.isBlank()) return false;
String norm = normalize(title);
Set<String> bigrams = bigramSet(norm);
if (bigrams.isEmpty()) return false;
for (String recallKey : recallTitleMap.keySet()) {
Set<String> recallBigrams = bigramSet(recallKey);
if (recallBigrams.isEmpty()) continue;
// Jaccard
Set<String> intersection = new HashSet<>(bigrams);
intersection.retainAll(recallBigrams);
Set<String> union = new HashSet<>(bigrams);
union.addAll(recallBigrams);
double similarity = (double) intersection.size() / union.size();
if (similarity >= 0.6) {
return true;
}
}
return false;
}
static Set<String> bigramSet(String s) {
Set<String> set = new HashSet<>();
for (int i = 0; i < s.length() - 1; i++) {
set.add(s.substring(i, i + 2));
}
return set;
}
}
@@ -5,12 +5,18 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.guo.learningprogresstracker.dto.PriorityDto;
import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapStruct.RequestConvert;
import com.guo.learningprogresstracker.mapStruct.TaskConvert;
import com.guo.learningprogresstracker.mapper.TaskApplicationMapper;
import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
@@ -18,6 +24,10 @@ import com.guo.learningprogresstracker.utils.GenerateNumTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
/**
* 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
@@ -30,6 +40,8 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
private final TasksMapper tasksMapper;
private final TaskApplicationMapper taskApplicationMapper;
@Override
public Page<TaskInfo> taskList(Integer pageNum, Integer pageSize) {
Page<TaskEntity> page = tasksMapper.selectPage(new Page<TaskEntity>(pageNum, pageSize),
@@ -86,4 +98,57 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
}
this.removeById(taskId);
}
@Override
public List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return taskApplicationMapper.selectList(
Wrappers.<TaskApplicationEntity>lambdaQuery()
.eq(TaskApplicationEntity::getTaskNum, taskNum)
.orderByDesc(TaskApplicationEntity::getLastModifiedTime)
.orderByDesc(TaskApplicationEntity::getCreatedTime));
}
@Override
public TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException {
ensureTaskExists(request.getTaskNum());
TaskApplicationEntity entity = new TaskApplicationEntity();
entity.setTaskNum(request.getTaskNum());
entity.setTitle(request.getTitle());
entity.setDescription(request.getDescription());
entity.setResourceUrl(request.getResourceUrl());
entity.setStatus(normalizeApplicationStatus(request.getStatus()));
taskApplicationMapper.insert(entity);
return entity;
}
@Override
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
existing.setTitle(request.getTitle());
existing.setDescription(request.getDescription());
existing.setResourceUrl(request.getResourceUrl());
existing.setStatus(normalizeApplicationStatus(request.getStatus()));
taskApplicationMapper.updateById(existing);
return existing;
}
@Override
public void deleteApplication(Integer id) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
taskApplicationMapper.deleteById(existing.getId());
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
}
}
private String normalizeApplicationStatus(String status) {
return TaskApplicationStatusEnum.fromCodeOrDefault(status).getCode();
}
}
@@ -0,0 +1,44 @@
package com.guo.learningprogresstracker.utils;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* 思维导图结构对比结果。
* 每个 {@link NodeDiff} 对应标准导图中的一个节点,标注其匹配状态及用户在回忆中撰写的标题。
*/
@Data
public class CompareResult {
/** 对比后的完整树(带 MATCHED/MISSED 标注) */
private MindMapNode matchedTree;
/** 用户追加但标准导图中不存在的节点 */
private List<FlatNode> extraNodes = new ArrayList<>();
/** 回忆覆盖率 0-1 */
private double recallRatio;
/** 命中节点数 */
private int matchedCount;
/** 遗漏节点数 */
private int missedCount;
/** 额外节点数 */
private int extraCount;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class FlatNode {
private String title;
private String path; // 以 / 分隔的路径
private String sourceType;
private Integer sourceId;
}
}
@@ -0,0 +1,95 @@
package com.guo.learningprogresstracker.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 思维导图树节点 DTO,与 MindMapFileParser 的输出结构兼容。
* 序列化后可在「nodes」和「children」两个 key 下放置子节点列表。
*
* <pre>{@code
* {
* "title": "根标题",
* "notes": "备注",
* "sourceType": "REPORT", // 可选:节点来源类型
* "sourceId": 1, // 可选:来源主键
* "children": [ ... ]
* }
* }</pre>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class MindMapNode {
private String title;
private String notes;
private String sourceType;
private Integer sourceId;
private List<MindMapNode> children;
public MindMapNode(String title) {
this.title = title;
this.notes = "";
this.children = new ArrayList<>();
}
/** 构建 JSON 友好的 Map 结构(兼容 MindMapFileParser 输出风格) */
@SuppressWarnings("unchecked")
public Map<String, Object> toMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", title != null ? title : "");
map.put("notes", notes != null ? notes : "");
if (sourceType != null) map.put("sourceType", sourceType);
if (sourceId != null) map.put("sourceId", sourceId);
List<Object> childMaps = new ArrayList<>();
if (children != null) {
for (MindMapNode c : children) {
childMaps.add(c.toMap());
}
}
map.put("children", childMaps);
return map;
}
/** 从 Map 重建节点 */
public static MindMapNode fromMap(Map<String, Object> map) {
MindMapNode node = new MindMapNode();
node.setTitle((String) map.getOrDefault("title", ""));
node.setNotes((String) map.getOrDefault("notes", ""));
node.setSourceType((String) map.get("sourceType"));
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
node.setSourceId(((Number) map.get("sourceId")).intValue());
}
Object raw = map.getOrDefault("children", map.get("nodes"));
List<MindMapNode> children = new ArrayList<>();
if (raw instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> m) {
children.add(fromMap((Map<String, Object>) m));
}
}
}
node.setChildren(children);
return node;
}
/** 反序列化 JSON 字符串为 MindMapNode */
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
try {
Map<String, Object> map = mapper.readValue(json, LinkedHashMap.class);
return fromMap(map);
} catch (Exception e) {
return new MindMapNode("解析失败");
}
}
}
@@ -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);
}
}
+9 -1
View File
@@ -50,4 +50,12 @@ management:
endpoints:
web:
exposure:
include: health,info # 或 "*"
include: health,info # 或 "*"
# 思维导图 AI 配置(可选,未配置时使用内置生成器)
lpt:
ai:
endpoint:
api-key:
model: claude-fable-5
timeout-seconds: 60
@@ -0,0 +1,41 @@
create table review_sessions
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
target_title varchar(255) not null comment '本次复习的具体知识目标',
recall_content text null comment '用户主动回忆内容',
knowledge_network text null comment '用户重构出的体系化知识网络',
reflection text null comment '复习后的反思与缺口',
status varchar(30) not null default 'ONGOING' comment '状态:ONGOING/COMPLETED',
started_time datetime not null comment '开始时间',
completed_time datetime null comment '完成时间',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_sessions_task_num (task_num)
)
comment '复习模块:完整复习会话';
create table review_records
(
id int auto_increment comment 'id无业务含义'
primary key,
review_session_id int not null comment '复习会话ID',
source_type varchar(30) not null comment '关联来源:REPORT/FRAGMENT',
source_id int not null comment '来源ID',
recall_level varchar(30) not null default 'UNCERTAIN' comment '回忆程度:REMEMBERED/UNCERTAIN/FORGOTTEN',
note text null comment '对照后的说明',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_records_session_id (review_session_id),
index idx_review_records_source (source_type, source_id)
)
comment '复习模块:完整复习完成后与学习报告/残片的对照关系';
@@ -0,0 +1 @@
alter table review_applications rename to task_applications;
@@ -0,0 +1,2 @@
drop table if exists review_records;
drop table if exists review_sessions;
@@ -0,0 +1,45 @@
create table review_standard_mind_maps
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
title varchar(255) not null comment '标准思维导图标题',
content mediumtext not null comment '标准导图统一树结构JSON',
outline mediumtext null comment '标准导图缩进大纲文本(用于展示与编辑)',
summary text null comment '生成摘要',
generator varchar(30) not null default 'BUILTIN' comment '生成来源:BUILTIN/AI/USER',
generator_version varchar(64) null comment '生成器版本或AI模型名',
source_report_count int default 0 not null comment '生成时参考的学习报告数',
source_fragment_count int default 0 not null comment '生成时参考的学习残片数',
generated_time datetime null comment '最近生成时间',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
unique key uk_review_standard_mind_maps_task_num (task_num)
)
comment '复习模块:任务标准思维导图(由内置规则或AI从学习报告/残片生成,用户可修改)';
create table review_recall_records
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
standard_map_id int null comment '对比时使用的标准导图ID',
recall_content mediumtext not null comment '用户回忆绘制的导图大纲文本',
compare_result mediumtext null comment '与标准导图的结构对比结果JSON',
recall_ratio double null comment '回忆覆盖率(0-1',
matched_count int default 0 not null comment '回忆命中的节点数',
missed_count int default 0 not null comment '遗漏的节点数',
extra_count int default 0 not null comment '标准导图之外的新增节点数',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_recall_records_task_num (task_num)
)
comment '复习模块:复习回忆与标准导图的对比记录';
@@ -3,18 +3,14 @@ package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.ReviewApplicationStatusEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
import com.guo.learningprogresstracker.mapper.ReviewApplicationMapper;
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
@@ -50,7 +46,6 @@ class ReviewServiceImplTest {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudyReportFragmentsEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), StudySessionsEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), ReviewApplicationEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), ReviewMindMapEntity.class);
}
@@ -69,9 +64,6 @@ class ReviewServiceImplTest {
@Mock
private TasksMapper tasksMapper;
@Mock
private ReviewApplicationMapper reviewApplicationMapper;
@Mock
private ReviewMindMapMapper reviewMindMapMapper;
@@ -109,27 +101,6 @@ class ReviewServiceImplTest {
assertEquals("Task", items.get(0).getTaskName());
}
@Test
void createApplication_shouldUseDefaultStatusWhenStatusInvalid() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
when(reviewApplicationMapper.insert(any())).thenAnswer(invocation -> {
ReviewApplicationEntity entity = invocation.getArgument(0);
entity.setId(7);
return 1;
});
CreateReviewApplicationRequest request = new CreateReviewApplicationRequest();
request.setTaskNum("T1");
request.setTitle("Build a demo");
request.setStatus("invalid");
ReviewApplicationEntity entity = reviewService.createApplication(request);
assertEquals(7, entity.getId());
assertEquals(ReviewApplicationStatusEnum.TODO.getCode(), entity.getStatus());
verify(reviewApplicationMapper).insert(any(ReviewApplicationEntity.class));
}
@Test
void upsertMindMap_shouldCreateWhenMissing() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
@@ -0,0 +1,195 @@
package com.guo.learningprogresstracker.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.*;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.CompareResult;
import com.guo.learningprogresstracker.utils.MindMapNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class StandardMindMapServiceImplTest {
@InjectMocks
private StandardMindMapServiceImpl service;
@Mock
private ReviewStandardMindMapMapper standardMindMapMapper;
@Mock
private ReviewRecallRecordMapper recallRecordMapper;
@Mock
private TasksMapper tasksMapper;
@Mock
private StudyReportsMapper studyReportsMapper;
@Mock
private StudyReportFragmentsMapper studyReportFragmentsMapper;
@Mock
private TaskApplicationMapper taskApplicationMapper;
@Mock
private StudySessionsMapper studySessionsMapper;
@Mock
private List<MindMapAiClient> aiClients;
@Mock
private MindMapAiClient mockAiClient;
private final ObjectMapper objectMapper = new ObjectMapper();
// ============ normalize / fuzzyMatch / bigramSet ============
@Test
void normalize_shouldStripPunctuationAndLowercase() {
assertEquals("javaconcurrency", StandardMindMapServiceImpl.normalize("Java Concurrency"));
assertEquals("测试abc", StandardMindMapServiceImpl.normalize("测试,ABC!?"));
assertEquals("helloworld", StandardMindMapServiceImpl.normalize(" Hello, World! "));
assertEquals("", StandardMindMapServiceImpl.normalize(null));
assertEquals("", StandardMindMapServiceImpl.normalize(""));
}
@Test
void normalize_shouldStripCompareMarkers() {
assertEquals("thread", StandardMindMapServiceImpl.normalize("MATCHED|Thread"));
assertEquals("thread", StandardMindMapServiceImpl.normalize("MISSED|Thread"));
}
@Test
void bigramSet_shouldExtractCharacterPairs() {
java.util.Set<String> set = StandardMindMapServiceImpl.bigramSet("hello");
assertTrue(set.contains("he"));
assertTrue(set.contains("el"));
assertTrue(set.contains("ll"));
assertTrue(set.contains("lo"));
assertEquals(4, set.size());
}
@Test
void bigramSet_shouldReturnEmptyForShortStrings() {
assertTrue(StandardMindMapServiceImpl.bigramSet("a").isEmpty());
assertTrue(StandardMindMapServiceImpl.bigramSet("").isEmpty());
}
// ============ compareTrees ============
@Test
void compareTrees_perfectMatch() {
service = createService();
MindMapNode standard = new MindMapNode("学习Java");
standard.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
MindMapNode recall = new MindMapNode("学习Java");
recall.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(2, result.getMatchedCount());
assertEquals(0, result.getMissedCount());
assertEquals(0, result.getExtraCount());
assertEquals(1.0, result.getRecallRatio(), 0.001);
}
@Test
void compareTrees_partialMatch() {
service = createService();
MindMapNode standard = new MindMapNode("学习Java");
standard.setChildren(List.of(
new MindMapNode("线程基础"),
new MindMapNode("锁机制"),
new MindMapNode("JVM内存模型")
));
MindMapNode recall = new MindMapNode("学习Java");
recall.setChildren(List.of(
new MindMapNode("线程基础"),
new MindMapNode("SpringBoot")
));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(1, result.getMatchedCount());
assertEquals(2, result.getMissedCount());
assertEquals(1, result.getExtraCount());
}
@Test
void compareTrees_noMatch() {
service = createService();
MindMapNode standard = new MindMapNode("学习Java");
standard.setChildren(List.of(new MindMapNode("线程基础"), new MindMapNode("锁机制")));
MindMapNode recall = new MindMapNode("完全不同");
recall.setChildren(List.of(new MindMapNode("前端开发")));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(0, result.getMatchedCount());
assertEquals(2, result.getMissedCount());
assertEquals(1, result.getExtraCount());
assertEquals(0.0, result.getRecallRatio(), 0.001);
}
// ============ 边缘情况 ============
@Test
void compareTrees_emptyStandardLeaves() {
service = createService();
MindMapNode standard = new MindMapNode(""); // 无子节点
MindMapNode recall = new MindMapNode("");
recall.setChildren(List.of(new MindMapNode("额外节点")));
CompareResult result = service.compareTrees(standard, recall);
assertEquals(0, result.getMatchedCount());
assertEquals(0, result.getMissedCount());
assertEquals(1, result.getExtraCount());
assertEquals(0.0, result.getRecallRatio(), 0.001);
}
@Test
void getOrGenerate_shouldThrowWhenTaskNotFound() {
when(tasksMapper.exists(any())).thenReturn(false);
assertThrows(NotFindEntitiesException.class, () -> service.getOrGenerate("NOT_EXIST"));
}
@Test
void getOrGenerate_shouldReturnExisting() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
ReviewStandardMindMapEntity existing = new ReviewStandardMindMapEntity();
existing.setId(1);
existing.setTaskNum("T1");
existing.setTitle("已有导图");
when(standardMindMapMapper.selectOne(any())).thenReturn(existing);
ReviewStandardMindMapEntity result = service.getOrGenerate("T1");
assertEquals("已有导图", result.getTitle());
verify(standardMindMapMapper, never()).insert(any());
}
// ============ 辅助 ============
private StandardMindMapServiceImpl createService() {
StandardMindMapServiceImpl s = new StandardMindMapServiceImpl(
standardMindMapMapper, recallRecordMapper,
tasksMapper, studyReportsMapper, studyReportFragmentsMapper,
taskApplicationMapper, studySessionsMapper,
List.of(mockAiClient), objectMapper);
return s;
}
}
@@ -0,0 +1,63 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
import com.guo.learningprogresstracker.mapper.TaskApplicationMapper;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TasksServiceImplTest {
@BeforeAll
static void initTableInfo() {
MybatisConfiguration configuration = new MybatisConfiguration();
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), TaskApplicationEntity.class);
}
@InjectMocks
private TasksServiceImpl tasksService;
@Mock
private TasksMapper tasksMapper;
@Mock
private TaskApplicationMapper taskApplicationMapper;
@Test
void createApplication_shouldUseDefaultStatusWhenStatusInvalid() throws Exception {
when(tasksMapper.exists(any())).thenReturn(true);
when(taskApplicationMapper.insert(any())).thenAnswer(invocation -> {
TaskApplicationEntity entity = invocation.getArgument(0);
entity.setId(7);
return 1;
});
CreateTaskApplicationRequest request = new CreateTaskApplicationRequest();
request.setTaskNum("T1");
request.setTitle("Build a demo");
request.setStatus("invalid");
TaskApplicationEntity entity = tasksService.createApplication(request);
assertEquals(7, entity.getId());
assertEquals(TaskApplicationStatusEnum.TODO.getCode(), entity.getStatus());
verify(taskApplicationMapper).insert(any(TaskApplicationEntity.class));
}
}