From 32b247526b176efc5833d68c360acfca2a47b4c9 Mon Sep 17 00:00:00 2001 From: cat_shark <1716967236@qq.com> Date: Fri, 3 Jul 2026 08:53:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=A4=8D=E4=B9=A0)=EF=BC=9A=E6=A0=87?= =?UTF-8?q?=E5=87=86=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=B8=8E=E5=9B=9E=E5=BF=86=E5=AF=B9=E6=AF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 review_standard_mind_maps / review_recall_records 数据表 - BuiltinMindMapGenerator:从报告/残片/应用场景规则生成标准导图 - MindMapAiClient 接口 + RemoteAiMindMapClient 预留 - StandardMindMapService:生成/编辑/重新生成/回忆对比 - 对比算法:标题归一化 + Bigram Jaccard 模糊匹配 - ReviewController 新增 6 个端点 - MindMapTreeTool / MindMapNode / CompareResult 工具类 - 完整单元测试(10 个,全部通过) - 更新 docs/review-module-design.md --- docs/review-module-design.md | 164 ++++++++ .../controller/ReviewController.java | 106 ++++-- .../controller/TaskController.java | 35 ++ ...java => CreateTaskApplicationRequest.java} | 3 +- .../dto/request/RecallCompareRequest.java | 14 + .../request/UpdateStandardMindMapRequest.java | 14 + ...java => UpdateTaskApplicationRequest.java} | 2 +- .../entity/ReviewRecallRecordEntity.java | 56 +++ .../entity/ReviewStandardMindMapEntity.java | 63 +++ ...Entity.java => TaskApplicationEntity.java} | 4 +- ...um.java => TaskApplicationStatusEnum.java} | 4 +- .../mapper/ReviewRecallRecordMapper.java | 9 + .../mapper/ReviewStandardMindMapMapper.java | 9 + ...Mapper.java => TaskApplicationMapper.java} | 4 +- .../service/MindMapAiClient.java | 44 +++ .../service/ReviewService.java | 23 -- .../service/StandardMindMapService.java | 48 +++ .../service/TasksService.java | 13 + .../service/impl/BuiltinMindMapGenerator.java | 181 +++++++++ .../service/impl/RemoteAiMindMapClient.java | 74 ++++ .../service/impl/ReviewServiceImpl.java | 52 --- .../impl/StandardMindMapServiceImpl.java | 359 ++++++++++++++++++ .../service/impl/TasksServiceImpl.java | 65 ++++ .../utils/CompareResult.java | 44 +++ .../utils/MindMapNode.java | 95 +++++ .../utils/MindMapTreeTool.java | 172 +++++++++ src/main/resources/application.yml | 10 +- ...620_3__add_review_sessions_and_records.sql | 41 ++ ...view_applications_to_task_applications.sql | 1 + ...621_2__drop_structured_review_sessions.sql | 2 + ..._standard_mind_maps_and_recall_records.sql | 45 +++ .../service/impl/ReviewServiceImplTest.java | 29 -- .../impl/StandardMindMapServiceImplTest.java | 195 ++++++++++ .../service/impl/TasksServiceImplTest.java | 63 +++ 34 files changed, 1890 insertions(+), 153 deletions(-) create mode 100644 docs/review-module-design.md rename src/main/java/com/guo/learningprogresstracker/dto/request/{CreateReviewApplicationRequest.java => CreateTaskApplicationRequest.java} (77%) create mode 100644 src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java create mode 100644 src/main/java/com/guo/learningprogresstracker/dto/request/UpdateStandardMindMapRequest.java rename src/main/java/com/guo/learningprogresstracker/dto/request/{UpdateReviewApplicationRequest.java => UpdateTaskApplicationRequest.java} (87%) create mode 100644 src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java create mode 100644 src/main/java/com/guo/learningprogresstracker/entity/ReviewStandardMindMapEntity.java rename src/main/java/com/guo/learningprogresstracker/entity/{ReviewApplicationEntity.java => TaskApplicationEntity.java} (86%) rename src/main/java/com/guo/learningprogresstracker/enums/{ReviewApplicationStatusEnum.java => TaskApplicationStatusEnum.java} (86%) create mode 100644 src/main/java/com/guo/learningprogresstracker/mapper/ReviewRecallRecordMapper.java create mode 100644 src/main/java/com/guo/learningprogresstracker/mapper/ReviewStandardMindMapMapper.java rename src/main/java/com/guo/learningprogresstracker/mapper/{ReviewApplicationMapper.java => TaskApplicationMapper.java} (50%) create mode 100644 src/main/java/com/guo/learningprogresstracker/service/MindMapAiClient.java create mode 100644 src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java create mode 100644 src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java create mode 100644 src/main/java/com/guo/learningprogresstracker/service/impl/RemoteAiMindMapClient.java create mode 100644 src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java create mode 100644 src/main/java/com/guo/learningprogresstracker/utils/CompareResult.java create mode 100644 src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java create mode 100644 src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java create mode 100644 src/main/resources/db/migration/V20260620_3__add_review_sessions_and_records.sql create mode 100644 src/main/resources/db/migration/V20260621_1__rename_review_applications_to_task_applications.sql create mode 100644 src/main/resources/db/migration/V20260621_2__drop_structured_review_sessions.sql create mode 100644 src/main/resources/db/migration/V20260702_1__add_standard_mind_maps_and_recall_records.sql create mode 100644 src/test/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImplTest.java create mode 100644 src/test/java/com/guo/learningprogresstracker/service/impl/TasksServiceImplTest.java diff --git a/docs/review-module-design.md b/docs/review-module-design.md new file mode 100644 index 0000000..0d039fd --- /dev/null +++ b/docs/review-module-design.md @@ -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 驱动的用户导图评分与改进建议 diff --git a/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java b/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java index 193a061..b5ba48b 100644 --- a/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java +++ b/src/main/java/com/guo/learningprogresstracker/controller/ReviewController.java @@ -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> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException { - return CommonResult.success(reviewService.getApplications(taskNum)); - } - - /** - * 创建应用场景 - */ - @PostMapping("/applications") - public CommonResult createApplication( - @Valid @RequestBody CreateReviewApplicationRequest request) throws NotFindEntitiesException { - return CommonResult.success(reviewService.createApplication(request)); - } - - /** - * 更新应用场景 - */ - @PutMapping("/applications/{id}") - public CommonResult updateApplication( - @PathVariable Integer id, - @Valid @RequestBody UpdateReviewApplicationRequest request) throws NotFindEntitiesException { - return CommonResult.success(reviewService.updateApplication(id, request)); - } - - /** - * 删除应用场景 - */ - @DeleteMapping("/applications/{id}") - public CommonResult 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 getStandardMindMap( + @PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException { + return CommonResult.success(standardMindMapService.getOrGenerate(taskNum)); + } + + /** + * 强制重新生成任务的标准思维导图 + */ + @PostMapping("/standard-mind-map/{taskNum}/regenerate") + public CommonResult regenerateStandardMindMap( + @PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException { + return CommonResult.success(standardMindMapService.regenerate(taskNum)); + } + + /** + * 用户编辑标准思维导图(大纲文本形式) + */ + @PutMapping("/standard-mind-map/{taskNum}") + public CommonResult 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 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> listRecallRecords( + @PathVariable String taskNum) throws NotFindEntitiesException { + return CommonResult.success(standardMindMapService.listRecallRecords(taskNum)); + } + + /** + * 获取单条回忆对比记录详情 + */ + @GetMapping("/standard-mind-map/recall-records/{recordId}") + public CommonResult getRecallRecord( + @PathVariable Integer recordId) throws NotFindEntitiesException { + return CommonResult.success(standardMindMapService.getRecallRecord(recordId)); + } } diff --git a/src/main/java/com/guo/learningprogresstracker/controller/TaskController.java b/src/main/java/com/guo/learningprogresstracker/controller/TaskController.java index 617ee06..4c6ef08 100644 --- a/src/main/java/com/guo/learningprogresstracker/controller/TaskController.java +++ b/src/main/java/com/guo/learningprogresstracker/controller/TaskController.java @@ -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> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException { + return CommonResult.success(tasksService.getApplications(taskNum)); + } + + @PostMapping("/{taskNum}/applications") + @Operation(summary = "创建任务应用场景") + public CommonResult 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 updateApplication( + @PathVariable Integer id, + @Valid @RequestBody UpdateTaskApplicationRequest request) throws NotFindEntitiesException { + return CommonResult.success(tasksService.updateApplication(id, request)); + } + + @DeleteMapping("/applications/{id}") + @Operation(summary = "删除任务应用场景") + public CommonResult deleteApplication(@PathVariable Integer id) throws NotFindEntitiesException { + tasksService.deleteApplication(id); + return CommonResult.success(); + } + @GetMapping @Operation(summary = "获取所有任务列表") public CommonResult> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum, diff --git a/src/main/java/com/guo/learningprogresstracker/dto/request/CreateReviewApplicationRequest.java b/src/main/java/com/guo/learningprogresstracker/dto/request/CreateTaskApplicationRequest.java similarity index 77% rename from src/main/java/com/guo/learningprogresstracker/dto/request/CreateReviewApplicationRequest.java rename to src/main/java/com/guo/learningprogresstracker/dto/request/CreateTaskApplicationRequest.java index 249e07a..b66c360 100644 --- a/src/main/java/com/guo/learningprogresstracker/dto/request/CreateReviewApplicationRequest.java +++ b/src/main/java/com/guo/learningprogresstracker/dto/request/CreateTaskApplicationRequest.java @@ -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 = "应用项目标题不可为空") diff --git a/src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java b/src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java new file mode 100644 index 0000000..b5ac15c --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/dto/request/RecallCompareRequest.java @@ -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; +} diff --git a/src/main/java/com/guo/learningprogresstracker/dto/request/UpdateStandardMindMapRequest.java b/src/main/java/com/guo/learningprogresstracker/dto/request/UpdateStandardMindMapRequest.java new file mode 100644 index 0000000..bff71f0 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/dto/request/UpdateStandardMindMapRequest.java @@ -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; +} diff --git a/src/main/java/com/guo/learningprogresstracker/dto/request/UpdateReviewApplicationRequest.java b/src/main/java/com/guo/learningprogresstracker/dto/request/UpdateTaskApplicationRequest.java similarity index 87% rename from src/main/java/com/guo/learningprogresstracker/dto/request/UpdateReviewApplicationRequest.java rename to src/main/java/com/guo/learningprogresstracker/dto/request/UpdateTaskApplicationRequest.java index 2f1fb76..17ae893 100644 --- a/src/main/java/com/guo/learningprogresstracker/dto/request/UpdateReviewApplicationRequest.java +++ b/src/main/java/com/guo/learningprogresstracker/dto/request/UpdateTaskApplicationRequest.java @@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data -public class UpdateReviewApplicationRequest { +public class UpdateTaskApplicationRequest { @NotBlank(message = "应用项目标题不可为空") private String title; diff --git a/src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java b/src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java new file mode 100644 index 0000000..329fea4 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/entity/ReviewRecallRecordEntity.java @@ -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; +} diff --git a/src/main/java/com/guo/learningprogresstracker/entity/ReviewStandardMindMapEntity.java b/src/main/java/com/guo/learningprogresstracker/entity/ReviewStandardMindMapEntity.java new file mode 100644 index 0000000..18c2702 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/entity/ReviewStandardMindMapEntity.java @@ -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; +} diff --git a/src/main/java/com/guo/learningprogresstracker/entity/ReviewApplicationEntity.java b/src/main/java/com/guo/learningprogresstracker/entity/TaskApplicationEntity.java similarity index 86% rename from src/main/java/com/guo/learningprogresstracker/entity/ReviewApplicationEntity.java rename to src/main/java/com/guo/learningprogresstracker/entity/TaskApplicationEntity.java index a17d8f2..f942a0e 100644 --- a/src/main/java/com/guo/learningprogresstracker/entity/ReviewApplicationEntity.java +++ b/src/main/java/com/guo/learningprogresstracker/entity/TaskApplicationEntity.java @@ -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; diff --git a/src/main/java/com/guo/learningprogresstracker/enums/ReviewApplicationStatusEnum.java b/src/main/java/com/guo/learningprogresstracker/enums/TaskApplicationStatusEnum.java similarity index 86% rename from src/main/java/com/guo/learningprogresstracker/enums/ReviewApplicationStatusEnum.java rename to src/main/java/com/guo/learningprogresstracker/enums/TaskApplicationStatusEnum.java index c0ad172..d0219af 100644 --- a/src/main/java/com/guo/learningprogresstracker/enums/ReviewApplicationStatusEnum.java +++ b/src/main/java/com/guo/learningprogresstracker/enums/TaskApplicationStatusEnum.java @@ -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; } diff --git a/src/main/java/com/guo/learningprogresstracker/mapper/ReviewRecallRecordMapper.java b/src/main/java/com/guo/learningprogresstracker/mapper/ReviewRecallRecordMapper.java new file mode 100644 index 0000000..4098a2b --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/mapper/ReviewRecallRecordMapper.java @@ -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 { +} diff --git a/src/main/java/com/guo/learningprogresstracker/mapper/ReviewStandardMindMapMapper.java b/src/main/java/com/guo/learningprogresstracker/mapper/ReviewStandardMindMapMapper.java new file mode 100644 index 0000000..2b023e3 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/mapper/ReviewStandardMindMapMapper.java @@ -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 { +} diff --git a/src/main/java/com/guo/learningprogresstracker/mapper/ReviewApplicationMapper.java b/src/main/java/com/guo/learningprogresstracker/mapper/TaskApplicationMapper.java similarity index 50% rename from src/main/java/com/guo/learningprogresstracker/mapper/ReviewApplicationMapper.java rename to src/main/java/com/guo/learningprogresstracker/mapper/TaskApplicationMapper.java index f689a48..517ab19 100644 --- a/src/main/java/com/guo/learningprogresstracker/mapper/ReviewApplicationMapper.java +++ b/src/main/java/com/guo/learningprogresstracker/mapper/TaskApplicationMapper.java @@ -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 { +public interface TaskApplicationMapper extends BaseMapper { } diff --git a/src/main/java/com/guo/learningprogresstracker/service/MindMapAiClient.java b/src/main/java/com/guo/learningprogresstracker/service/MindMapAiClient.java new file mode 100644 index 0000000..dd8c25e --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/service/MindMapAiClient.java @@ -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 生成客户端抽象。 + *

后续接入真实 AI 时实现此接口,通过 Spring {@code @Profile} / {@code @ConditionalOnProperty} 切换。 + * 当前由内置规则生成器 {@code BuiltinMindMapGenerator} 充当。

+ */ +public interface MindMapAiClient { + + /** + * 是否可用(例如:API Key 已配置) + */ + boolean isAvailable(); + + /** + * 根据学习数据生成标准思维导图根节点 + * + * @param task 学习任务 + * @param reports 该任务的全部学习报告 + * @param fragments 该任务的全部学习残片 + * @param applications 该任务的应用场景(可选) + * @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成) + * @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()} + */ + Optional generate(TaskEntity task, + List reports, + List fragments, + List applications, + String clientHint); + + /** + * 获取生成器标识(如 {@code BUILTIN} / {@code AI-4.5}) + */ + String generatorName(); +} diff --git a/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java b/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java index 59c9d33..04bde20 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java +++ b/src/main/java/com/guo/learningprogresstracker/service/ReviewService.java @@ -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 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; - /** * 获取指定任务的思维导图 */ diff --git a/src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java b/src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java new file mode 100644 index 0000000..78ae950 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/service/StandardMindMapService.java @@ -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; + +/** + * 标准思维导图服务。 + *

负责标准导图的按需生成、查询、用户编辑,以及用户回忆导图的对比。

+ */ +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 listRecallRecords(String taskNum) throws NotFindEntitiesException; + + /** + * 获取单条对比记录详情。 + */ + ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException; +} diff --git a/src/main/java/com/guo/learningprogresstracker/service/TasksService.java b/src/main/java/com/guo/learningprogresstracker/service/TasksService.java index c047621..0a9d636 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/TasksService.java +++ b/src/main/java/com/guo/learningprogresstracker/service/TasksService.java @@ -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 { void updateTask(String taskId, TaskRequest updatedTask); void deleteTask(String taskId) throws ServerException; + + List 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; } diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java b/src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java new file mode 100644 index 0000000..e6be4a9 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/BuiltinMindMapGenerator.java @@ -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; + +/** + * 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。 + *

规则策略:

+ *
    + *
  1. 根节点 = 任务名称
  2. + *
  3. 一级分支 = 按会话(日期+报告摘要)分组
  4. + *
  5. 二级分支 = 该会话下的残片标题
  6. + *
  7. 附加分支"应用场景" = 任务应用场景(如有)
  8. + *
  9. 去重:标准化后标题对比,合并内容相似的节点
  10. + *
  11. 引用追溯:每个节点携带 sourceType / sourceId
  12. + *
+ */ +@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 generate(TaskEntity task, + List reports, + List fragments, + List 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> reportsBySession = reports.stream() + .collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum)); + Map> fragmentsBySession = fragments.stream() + .collect(Collectors.groupingBy(StudyReportFragmentsEntity::getSessionNum)); + + // 合并所有 session + Set allSessions = new LinkedHashSet<>(); + allSessions.addAll(reportsBySession.keySet()); + allSessions.addAll(fragmentsBySession.keySet()); + + for (String sessionNum : allSessions) { + List sessReports = reportsBySession.getOrDefault(sessionNum, List.of()); + List 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 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")); + } + +} diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/RemoteAiMindMapClient.java b/src/main/java/com/guo/learningprogresstracker/service/impl/RemoteAiMindMapClient.java new file mode 100644 index 0000000..7b22be8 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/RemoteAiMindMapClient.java @@ -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 思维导图生成客户端(预留实现)。 + *

通过 {@code lpt.ai.endpoint} / {@code lpt.ai.api-key} 配置;未配置时不可用, + * 上层服务会回退到 {@link BuiltinMindMapGenerator}。

+ */ +@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 generate(TaskEntity task, + List reports, + List fragments, + List 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(); + } +} diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java b/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java index 83cbc43..029360b 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImpl.java @@ -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 getApplications(String taskNum) throws NotFindEntitiesException { - ensureTaskExists(taskNum); - return reviewApplicationMapper.selectList( - Wrappers.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; diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java b/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java new file mode 100644 index 0000000..02ec4a8 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java @@ -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 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 standardFlat = MindMapTreeTool.flatten(standardRoot); + // 展平回忆树(排除根节点本身) + List recallFlat = MindMapTreeTool.flatten(recallRoot).stream() + .filter(n -> n != recallRoot) + .collect(Collectors.toList()); + + // 构建回忆节点标题 → 节点映射(标准化后) + Map 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 standardNormTitles = standardFlat.stream() + .map(n -> normalize(n.getTitle())) + .collect(Collectors.toSet()); + + List 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 listRecallRecords(String taskNum) throws NotFindEntitiesException { + ensureTaskExists(taskNum); + return recallRecordMapper.selectList( + Wrappers.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.lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1")); + + if (task == null) { + throw new OperationFailedException("任务[" + taskNum + "]不存在"); + } + + // 收集学习数据 + List sessionNums = studySessionsMapper.selectList( + Wrappers.lambdaQuery() + .eq(StudySessionsEntity::getTaskNum, taskNum) + .select(StudySessionsEntity::getSessionNum)) + .stream().map(StudySessionsEntity::getSessionNum).collect(Collectors.toList()); + + List reports = sessionNums.isEmpty() ? List.of() + : studyReportsMapper.selectList(Wrappers.lambdaQuery() + .in(StudyReportsEntity::getSessionNum, sessionNums)); + List fragments = sessionNums.isEmpty() ? List.of() + : studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery() + .in(StudyReportFragmentsEntity::getSessionNum, sessionNums)); + List applications = taskApplicationMapper.selectList( + Wrappers.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 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.lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) { + throw new NotFindEntitiesException("任务[" + taskNum + "]不存在"); + } + } + + private ReviewStandardMindMapEntity queryByTaskNum(String taskNum) { + return standardMindMapMapper.selectOne( + Wrappers.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 recallTitleMap) { + if (title == null || title.isBlank()) return false; + String norm = normalize(title); + Set bigrams = bigramSet(norm); + if (bigrams.isEmpty()) return false; + + for (String recallKey : recallTitleMap.keySet()) { + Set recallBigrams = bigramSet(recallKey); + if (recallBigrams.isEmpty()) continue; + // Jaccard + Set intersection = new HashSet<>(bigrams); + intersection.retainAll(recallBigrams); + Set union = new HashSet<>(bigrams); + union.addAll(recallBigrams); + double similarity = (double) intersection.size() / union.size(); + if (similarity >= 0.6) { + return true; + } + } + return false; + } + + static Set bigramSet(String s) { + Set set = new HashSet<>(); + for (int i = 0; i < s.length() - 1; i++) { + set.add(s.substring(i, i + 2)); + } + return set; + } +} diff --git a/src/main/java/com/guo/learningprogresstracker/service/impl/TasksServiceImpl.java b/src/main/java/com/guo/learningprogresstracker/service/impl/TasksServiceImpl.java index ae3ed84..dd050d7 100644 --- a/src/main/java/com/guo/learningprogresstracker/service/impl/TasksServiceImpl.java +++ b/src/main/java/com/guo/learningprogresstracker/service/impl/TasksServiceImpl.java @@ -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 private final TasksMapper tasksMapper; + private final TaskApplicationMapper taskApplicationMapper; + @Override public Page taskList(Integer pageNum, Integer pageSize) { Page page = tasksMapper.selectPage(new Page(pageNum, pageSize), @@ -86,4 +98,57 @@ public class TasksServiceImpl extends ServiceImpl } this.removeById(taskId); } + + @Override + public List getApplications(String taskNum) throws NotFindEntitiesException { + ensureTaskExists(taskNum); + return taskApplicationMapper.selectList( + Wrappers.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.lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) { + throw new NotFindEntitiesException("任务[" + taskNum + "]不存在"); + } + } + + private String normalizeApplicationStatus(String status) { + return TaskApplicationStatusEnum.fromCodeOrDefault(status).getCode(); + } } diff --git a/src/main/java/com/guo/learningprogresstracker/utils/CompareResult.java b/src/main/java/com/guo/learningprogresstracker/utils/CompareResult.java new file mode 100644 index 0000000..4a041bb --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/utils/CompareResult.java @@ -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 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; + } +} diff --git a/src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java b/src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java new file mode 100644 index 0000000..78d126a --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/utils/MindMapNode.java @@ -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 下放置子节点列表。 + * + *
{@code
+ * {
+ *   "title": "根标题",
+ *   "notes": "备注",
+ *   "sourceType": "REPORT",   // 可选:节点来源类型
+ *   "sourceId": 1,            // 可选:来源主键
+ *   "children": [ ... ]
+ * }
+ * }
+ */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Accessors(chain = true) +public class MindMapNode { + + private String title; + private String notes; + private String sourceType; + private Integer sourceId; + private List children; + + public MindMapNode(String title) { + this.title = title; + this.notes = ""; + this.children = new ArrayList<>(); + } + + /** 构建 JSON 友好的 Map 结构(兼容 MindMapFileParser 输出风格) */ + @SuppressWarnings("unchecked") + public Map toMap() { + Map 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 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 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 children = new ArrayList<>(); + if (raw instanceof List list) { + for (Object item : list) { + if (item instanceof Map m) { + children.add(fromMap((Map) m)); + } + } + } + node.setChildren(children); + return node; + } + + /** 反序列化 JSON 字符串为 MindMapNode */ + public static MindMapNode fromJson(String json, ObjectMapper mapper) { + try { + Map map = mapper.readValue(json, LinkedHashMap.class); + return fromMap(map); + } catch (Exception e) { + return new MindMapNode("解析失败"); + } + } +} diff --git a/src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java b/src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java new file mode 100644 index 0000000..2474682 --- /dev/null +++ b/src/main/java/com/guo/learningprogresstracker/utils/MindMapTreeTool.java @@ -0,0 +1,172 @@ +package com.guo.learningprogresstracker.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; + +import java.util.*; + +/** + * 思维导图树结构与缩进大纲文本之间的双向转换工具。 + *

大纲格式:每行一条节点,缩进表示层级(2 空格 / tab / # 前缀),行首 - * 数字. 等标记被自动剥离。

+ */ +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 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 roots = new ArrayList<>(); + + Deque 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 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 children) { + } + + // ============ 工具方法 ============ + + /** 展开树为平铺列表(前序遍历) */ + public static List flatten(MindMapNode root) { + List list = new ArrayList<>(); + flattenRecursive(root, list); + return list; + } + + private static void flattenRecursive(MindMapNode node, List 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); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 369bfdc..62e203d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,4 +50,12 @@ management: endpoints: web: exposure: - include: health,info # 或 "*" \ No newline at end of file + include: health,info # 或 "*" + +# 思维导图 AI 配置(可选,未配置时使用内置生成器) +lpt: + ai: + endpoint: + api-key: + model: claude-fable-5 + timeout-seconds: 60 \ No newline at end of file diff --git a/src/main/resources/db/migration/V20260620_3__add_review_sessions_and_records.sql b/src/main/resources/db/migration/V20260620_3__add_review_sessions_and_records.sql new file mode 100644 index 0000000..124b1f1 --- /dev/null +++ b/src/main/resources/db/migration/V20260620_3__add_review_sessions_and_records.sql @@ -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 '复习模块:完整复习完成后与学习报告/残片的对照关系'; diff --git a/src/main/resources/db/migration/V20260621_1__rename_review_applications_to_task_applications.sql b/src/main/resources/db/migration/V20260621_1__rename_review_applications_to_task_applications.sql new file mode 100644 index 0000000..2f866af --- /dev/null +++ b/src/main/resources/db/migration/V20260621_1__rename_review_applications_to_task_applications.sql @@ -0,0 +1 @@ +alter table review_applications rename to task_applications; diff --git a/src/main/resources/db/migration/V20260621_2__drop_structured_review_sessions.sql b/src/main/resources/db/migration/V20260621_2__drop_structured_review_sessions.sql new file mode 100644 index 0000000..7241a18 --- /dev/null +++ b/src/main/resources/db/migration/V20260621_2__drop_structured_review_sessions.sql @@ -0,0 +1,2 @@ +drop table if exists review_records; +drop table if exists review_sessions; diff --git a/src/main/resources/db/migration/V20260702_1__add_standard_mind_maps_and_recall_records.sql b/src/main/resources/db/migration/V20260702_1__add_standard_mind_maps_and_recall_records.sql new file mode 100644 index 0000000..af0c9d7 --- /dev/null +++ b/src/main/resources/db/migration/V20260702_1__add_standard_mind_maps_and_recall_records.sql @@ -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 '复习模块:复习回忆与标准导图的对比记录'; diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java index e7ef2f3..8483f97 100644 --- a/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/ReviewServiceImplTest.java @@ -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); diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImplTest.java new file mode 100644 index 0000000..291c840 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImplTest.java @@ -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 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 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; + } +} diff --git a/src/test/java/com/guo/learningprogresstracker/service/impl/TasksServiceImplTest.java b/src/test/java/com/guo/learningprogresstracker/service/impl/TasksServiceImplTest.java new file mode 100644 index 0000000..18022e5 --- /dev/null +++ b/src/test/java/com/guo/learningprogresstracker/service/impl/TasksServiceImplTest.java @@ -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)); + } +}