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