feat(复习):标准思维导图生成与回忆对比

- 新增 review_standard_mind_maps / review_recall_records 数据表
- BuiltinMindMapGenerator:从报告/残片/应用场景规则生成标准导图
- MindMapAiClient 接口 + RemoteAiMindMapClient 预留
- StandardMindMapService:生成/编辑/重新生成/回忆对比
- 对比算法:标题归一化 + Bigram Jaccard 模糊匹配
- ReviewController 新增 6 个端点
- MindMapTreeTool / MindMapNode / CompareResult 工具类
- 完整单元测试(10 个,全部通过)
- 更新 docs/review-module-design.md
This commit is contained in:
2026-07-03 08:53:51 +08:00
parent 5f3f35c53b
commit 32b247526b
34 changed files with 1890 additions and 153 deletions
@@ -2,16 +2,19 @@ package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpdateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import com.guo.learningprogresstracker.service.ReviewService;
import com.guo.learningprogresstracker.service.StandardMindMapService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
@@ -21,6 +24,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.List;
/**
* 复习模块控制层 — 复习内容滚动展示与交互
@@ -33,6 +37,8 @@ public class ReviewController {
private final ReviewService reviewService;
private final StandardMindMapService standardMindMapService;
/**
* 获取复习 feed,合并报告和残片按时间倒序
*/
@@ -83,42 +89,6 @@ public class ReviewController {
return CommonResult.success(reviewService.getFragmentDetail(id));
}
/**
* 获取指定任务的应用场景列表
*/
@GetMapping("/applications/{taskNum}")
public CommonResult<List<ReviewApplicationEntity>> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(reviewService.getApplications(taskNum));
}
/**
* 创建应用场景
*/
@PostMapping("/applications")
public CommonResult<ReviewApplicationEntity> createApplication(
@Valid @RequestBody CreateReviewApplicationRequest request) throws NotFindEntitiesException {
return CommonResult.success(reviewService.createApplication(request));
}
/**
* 更新应用场景
*/
@PutMapping("/applications/{id}")
public CommonResult<ReviewApplicationEntity> updateApplication(
@PathVariable Integer id,
@Valid @RequestBody UpdateReviewApplicationRequest request) throws NotFindEntitiesException {
return CommonResult.success(reviewService.updateApplication(id, request));
}
/**
* 删除应用场景
*/
@DeleteMapping("/applications/{id}")
public CommonResult<Void> deleteApplication(@PathVariable Integer id) throws NotFindEntitiesException {
reviewService.deleteApplication(id);
return CommonResult.success();
}
/**
* 获取指定任务的思维导图
*/
@@ -146,4 +116,62 @@ public class ReviewController {
@RequestParam("file") MultipartFile file) throws NotFindEntitiesException, IOException {
return CommonResult.success(reviewService.uploadMindMap(taskNum, file));
}
// ============ 标准思维导图与回忆对比 ============
/**
* 获取或自动生成任务的标准思维导图
*/
@GetMapping("/standard-mind-map/{taskNum}")
public CommonResult<ReviewStandardMindMapEntity> getStandardMindMap(
@PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.getOrGenerate(taskNum));
}
/**
* 强制重新生成任务的标准思维导图
*/
@PostMapping("/standard-mind-map/{taskNum}/regenerate")
public CommonResult<ReviewStandardMindMapEntity> regenerateStandardMindMap(
@PathVariable String taskNum) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.regenerate(taskNum));
}
/**
* 用户编辑标准思维导图(大纲文本形式)
*/
@PutMapping("/standard-mind-map/{taskNum}")
public CommonResult<ReviewStandardMindMapEntity> updateStandardMindMap(
@PathVariable String taskNum,
@Valid @RequestBody UpdateStandardMindMapRequest request) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.updateByOutline(taskNum, request.getOutline()));
}
/**
* 用户提交回忆大纲,与标准导图对比
*/
@PostMapping("/standard-mind-map/{taskNum}/recall")
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
@PathVariable String taskNum,
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline()));
}
/**
* 获取该任务的所有回忆对比记录
*/
@GetMapping("/standard-mind-map/{taskNum}/recall-records")
public CommonResult<List<ReviewRecallRecordEntity>> listRecallRecords(
@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.listRecallRecords(taskNum));
}
/**
* 获取单条回忆对比记录详情
*/
@GetMapping("/standard-mind-map/recall-records/{recordId}")
public CommonResult<ReviewRecallRecordEntity> getRecallRecord(
@PathVariable Integer recordId) throws NotFindEntitiesException {
return CommonResult.success(standardMindMapService.getRecallRecord(recordId));
}
}
@@ -4,15 +4,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.common.Ops;
import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
@@ -27,6 +31,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.rmi.ServerException;
import java.util.List;
/**
* 任务控制层
@@ -69,6 +74,36 @@ public class TaskController {
return CommonResult.success();
}
@GetMapping("/{taskNum}/applications")
@Operation(summary = "获取任务应用场景列表")
public CommonResult<List<TaskApplicationEntity>> getApplications(@PathVariable String taskNum) throws NotFindEntitiesException {
return CommonResult.success(tasksService.getApplications(taskNum));
}
@PostMapping("/{taskNum}/applications")
@Operation(summary = "创建任务应用场景")
public CommonResult<TaskApplicationEntity> createApplication(
@PathVariable String taskNum,
@Valid @RequestBody CreateTaskApplicationRequest request) throws NotFindEntitiesException {
request.setTaskNum(taskNum);
return CommonResult.success(tasksService.createApplication(request));
}
@PutMapping("/applications/{id}")
@Operation(summary = "更新任务应用场景")
public CommonResult<TaskApplicationEntity> updateApplication(
@PathVariable Integer id,
@Valid @RequestBody UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
return CommonResult.success(tasksService.updateApplication(id, request));
}
@DeleteMapping("/applications/{id}")
@Operation(summary = "删除任务应用场景")
public CommonResult<Void> deleteApplication(@PathVariable Integer id) throws NotFindEntitiesException {
tasksService.deleteApplication(id);
return CommonResult.success();
}
@GetMapping
@Operation(summary = "获取所有任务列表")
public CommonResult<Page<TaskInfo>> tasksList(@RequestParam(value = "pageNum", defaultValue = "1") Integer pageNum,
@@ -4,9 +4,8 @@ import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CreateReviewApplicationRequest {
public class CreateTaskApplicationRequest {
@NotBlank(message = "任务编号不可为空")
private String taskNum;
@NotBlank(message = "应用项目标题不可为空")
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 用户回忆对比请求
*/
@Data
public class RecallCompareRequest {
@NotBlank(message = "回忆大纲不可为空")
private String recallOutline;
}
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 更新标准思维导图(大纲文本形式)请求
*/
@Data
public class UpdateStandardMindMapRequest {
@NotBlank(message = "思维导图大纲不可为空")
private String outline;
}
@@ -4,7 +4,7 @@ import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateReviewApplicationRequest {
public class UpdateTaskApplicationRequest {
@NotBlank(message = "应用项目标题不可为空")
private String title;
@@ -0,0 +1,56 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
/**
* 复习回忆与标准导图的对比记录
*/
@TableName(value = "review_recall_records")
@Data
public class ReviewRecallRecordEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "task_num")
private String taskNum;
@TableField(value = "standard_map_id")
private Integer standardMapId;
/**
* 用户回忆绘制的导图大纲文本
*/
@TableField(value = "recall_content")
private String recallContent;
/**
* 与标准导图的结构对比结果 JSON
*/
@TableField(value = "compare_result")
private String compareResult;
/**
* 回忆覆盖率(0-1
*/
@TableField(value = "recall_ratio")
private Double recallRatio;
@TableField(value = "matched_count")
private Integer matchedCount;
@TableField(value = "missed_count")
private Integer missedCount;
@TableField(value = "extra_count")
private Integer extraCount;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -0,0 +1,63 @@
package com.guo.learningprogresstracker.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 任务标准思维导图:由内置规则或 AI 从学习报告/残片生成,用户可修改
*/
@TableName(value = "review_standard_mind_maps")
@Data
public class ReviewStandardMindMapEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@TableField(value = "task_num")
private String taskNum;
@TableField(value = "title")
private String title;
/**
* 标准导图统一树结构 JSON(根节点:{title, notes, children}
*/
@TableField(value = "content")
private String content;
/**
* 缩进大纲文本,供前端展示与用户编辑
*/
@TableField(value = "outline")
private String outline;
@TableField(value = "summary")
private String summary;
/**
* 生成来源:BUILTIN/AI/USER
*/
@TableField(value = "generator")
private String generator;
@TableField(value = "generator_version")
private String generatorVersion;
@TableField(value = "source_report_count")
private Integer sourceReportCount;
@TableField(value = "source_fragment_count")
private Integer sourceFragmentCount;
@TableField(value = "generated_time")
private LocalDateTime generatedTime;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
@@ -8,9 +8,9 @@ import lombok.Data;
import java.io.Serializable;
@TableName(value = "review_applications")
@TableName(value = "task_applications")
@Data
public class ReviewApplicationEntity extends BaseEntity implements Serializable {
public class TaskApplicationEntity extends BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
@@ -6,7 +6,7 @@ import java.util.Arrays;
import java.util.Locale;
@AllArgsConstructor
public enum ReviewApplicationStatusEnum {
public enum TaskApplicationStatusEnum {
TODO("TODO", "待应用"),
DOING("DOING", "应用中"),
DONE("DONE", "已完成");
@@ -22,7 +22,7 @@ public enum ReviewApplicationStatusEnum {
return description;
}
public static ReviewApplicationStatusEnum fromCodeOrDefault(String code) {
public static TaskApplicationStatusEnum fromCodeOrDefault(String code) {
if (code == null || code.trim().isEmpty()) {
return TODO;
}
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewRecallRecordMapper extends BaseMapper<ReviewRecallRecordEntity> {
}
@@ -0,0 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewStandardMindMapMapper extends BaseMapper<ReviewStandardMindMapEntity> {
}
@@ -1,9 +1,9 @@
package com.guo.learningprogresstracker.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReviewApplicationMapper extends BaseMapper<ReviewApplicationEntity> {
public interface TaskApplicationMapper extends BaseMapper<TaskApplicationEntity> {
}
@@ -0,0 +1,44 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.utils.MindMapNode;
import java.util.List;
import java.util.Optional;
/**
* 思维导图 AI 生成客户端抽象。
* <p>后续接入真实 AI 时实现此接口,通过 Spring {@code @Profile} / {@code @ConditionalOnProperty} 切换。
* 当前由内置规则生成器 {@code BuiltinMindMapGenerator} 充当。</p>
*/
public interface MindMapAiClient {
/**
* 是否可用(例如:API Key 已配置)
*/
boolean isAvailable();
/**
* 根据学习数据生成标准思维导图根节点
*
* @param task 学习任务
* @param reports 该任务的全部学习报告
* @param fragments 该任务的全部学习残片
* @param applications 该任务的应用场景(可选)
* @param clientHint 前端已有的大纲文本(可选,用于 AI 续写而非全量生成)
* @return 标准思维导图的根节点;若无可生成数据则返回 {@link Optional#empty()}
*/
Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint);
/**
* 获取生成器标识(如 {@code BUILTIN} / {@code AI-4.5}
*/
String generatorName();
}
@@ -2,10 +2,7 @@ package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpdateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
@@ -51,26 +48,6 @@ public interface ReviewService {
*/
StudyReportFragmentsEntity getFragmentDetail(int id) throws NotFindEntitiesException;
/**
* 获取指定任务的应用场景列表
*/
List<ReviewApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException;
/**
* 创建应用场景
*/
ReviewApplicationEntity createApplication(CreateReviewApplicationRequest request) throws NotFindEntitiesException;
/**
* 更新应用场景
*/
ReviewApplicationEntity updateApplication(Integer id, UpdateReviewApplicationRequest request) throws NotFindEntitiesException;
/**
* 删除应用场景
*/
void deleteApplication(Integer id) throws NotFindEntitiesException;
/**
* 获取指定任务的思维导图
*/
@@ -0,0 +1,48 @@
package com.guo.learningprogresstracker.service;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import java.util.List;
/**
* 标准思维导图服务。
* <p>负责标准导图的按需生成、查询、用户编辑,以及用户回忆导图的对比。</p>
*/
public interface StandardMindMapService {
/**
* 获取指定任务的标准思维导图,不存在时自动生成。
*/
ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 强制重新生成指定任务的标准思维导图。
*/
ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException;
/**
* 用户手动编辑标准思维导图(用缩进大纲文本替换)。
*/
ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException;
/**
* 用户提交回忆大纲,与标准导图对比并返回结果。
*
* @param recallOutline 用户回忆的缩进大纲文本
* @return 对比记录实体(compareResult 字段包含对比树 JSON
*/
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline) throws NotFindEntitiesException, OperationFailedException;
/**
* 获取该任务的所有回忆对比记录。
*/
List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException;
/**
* 获取单条对比记录详情。
*/
ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException;
}
@@ -3,12 +3,17 @@ package com.guo.learningprogresstracker.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import java.rmi.ServerException;
import java.util.List;
/**
* @author guo
@@ -26,4 +31,12 @@ public interface TasksService extends IService<TaskEntity> {
void updateTask(String taskId, TaskRequest updatedTask);
void deleteTask(String taskId) throws ServerException;
List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException;
TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException;
TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException;
void deleteApplication(Integer id) throws NotFindEntitiesException;
}
@@ -0,0 +1,181 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 内置规则引擎:从学习报告和残片提取关键词并组织为树形思维导图。
* <p>规则策略:</p>
* <ol>
* <li>根节点 = 任务名称</li>
* <li>一级分支 = 按会话(日期+报告摘要)分组</li>
* <li>二级分支 = 该会话下的残片标题</li>
* <li>附加分支"应用场景" = 任务应用场景(如有)</li>
* <li>去重:标准化后标题对比,合并内容相似的节点</li>
* <li>引用追溯:每个节点携带 sourceType / sourceId</li>
* </ol>
*/
@Slf4j
@Component
public class BuiltinMindMapGenerator implements MindMapAiClient {
private static final int MAX_TITLE_LENGTH = 60;
@Override
public boolean isAvailable() {
return true; // 始终可用
}
@Override
public String generatorName() {
return "BUILTIN";
}
@Override
public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint) {
if (reports.isEmpty() && fragments.isEmpty()) {
return Optional.empty();
}
MindMapNode root = new MindMapNode(task.getTaskName() != null ? task.getTaskName() : "学习任务");
root.setNotes(task.getTaskDescription() != null ? task.getTaskDescription() : "");
// 按 session 分组
Map<String, List<StudyReportsEntity>> reportsBySession = reports.stream()
.collect(Collectors.groupingBy(StudyReportsEntity::getSessionNum));
Map<String, List<StudyReportFragmentsEntity>> fragmentsBySession = fragments.stream()
.collect(Collectors.groupingBy(StudyReportFragmentsEntity::getSessionNum));
// 合并所有 session
Set<String> allSessions = new LinkedHashSet<>();
allSessions.addAll(reportsBySession.keySet());
allSessions.addAll(fragmentsBySession.keySet());
for (String sessionNum : allSessions) {
List<StudyReportsEntity> sessReports = reportsBySession.getOrDefault(sessionNum, List.of());
List<StudyReportFragmentsEntity> sessFragments = fragmentsBySession.getOrDefault(sessionNum, List.of());
// 会话分支标题:取第一条报告的前 60 字作为摘要,或直接写"学习记录"
String sessionTitle;
if (!sessReports.isEmpty()) {
String firstReport = sessReports.get(0).getContent();
sessionTitle = truncate(firstReport, MAX_TITLE_LENGTH);
if (sessReports.get(0).getCreatedTime() != null) {
sessionTitle = formatDate(sessReports.get(0).getCreatedTime()) + " " + sessionTitle;
}
} else {
sessionTitle = "学习记录 " + (sessFragments.isEmpty() ? "" : formatDate(sessFragments.get(0).getCreatedTime()));
}
MindMapNode sessionNode = new MindMapNode(sessionTitle);
// 报告作为子节点
for (StudyReportsEntity report : sessReports) {
String content = report.getContent();
if (content == null || content.isBlank()) continue;
MindMapNode reportNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
reportNode.setNotes(content);
reportNode.setSourceType("REPORT");
reportNode.setSourceId(report.getId());
sessionNode.getChildren().add(reportNode);
}
// 残片作为子节点
for (StudyReportFragmentsEntity frag : sessFragments) {
String content = frag.getContent();
if (content == null || content.isBlank()) continue;
MindMapNode fragNode = new MindMapNode(truncate(content, MAX_TITLE_LENGTH));
fragNode.setNotes(content);
fragNode.setSourceType("FRAGMENT");
fragNode.setSourceId(frag.getId());
sessionNode.getChildren().add(fragNode);
}
root.getChildren().add(sessionNode);
}
// 应用场景分支(如有)
if (applications != null && !applications.isEmpty()) {
MindMapNode appNode = new MindMapNode("应用场景");
for (TaskApplicationEntity app : applications) {
if (app.getTitle() == null || app.getTitle().isBlank()) continue;
MindMapNode child = new MindMapNode(app.getTitle());
child.setNotes(app.getDescription() != null ? app.getDescription() : "");
child.setSourceType("APPLICATION");
child.setSourceId(app.getId());
appNode.getChildren().add(child);
}
if (!appNode.getChildren().isEmpty()) {
root.getChildren().add(appNode);
}
}
// 去重:同级 title 标准化后合并
deduplicateChildren(root);
log.info("BuiltinMindMapGenerator: 为任务[{}]生成导图,共 {} 个节点,{} 层",
task.getTaskNum(), MindMapTreeTool.countNodes(root), MindMapTreeTool.maxDepth(root));
return Optional.of(root);
}
/** 同级节点按标准化标题去重 */
private void deduplicateChildren(MindMapNode node) {
if (node.getChildren() == null || node.getChildren().isEmpty()) return;
Map<String, MindMapNode> seen = new LinkedHashMap<>();
for (MindMapNode child : node.getChildren()) {
String key = normalize(child.getTitle());
seen.merge(key, child, (a, b) -> {
// 保留 notes 较长的那个
if (b.getNotes() != null && b.getNotes().length() > (a.getNotes() != null ? a.getNotes().length() : 0)) {
a.setNotes(b.getNotes());
}
// 合并子节点
if (b.getChildren() != null) {
a.getChildren().addAll(b.getChildren());
}
return a;
});
}
node.setChildren(new ArrayList<>(seen.values()));
for (MindMapNode child : node.getChildren()) {
deduplicateChildren(child);
}
}
private static String normalize(String s) {
if (s == null) return "";
return s.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "")
.toLowerCase(Locale.ROOT)
.trim();
}
private static String truncate(String s, int maxLen) {
if (s == null) return "";
if (s.length() <= maxLen) return s;
return s.substring(0, maxLen) + "";
}
private static String formatDate(LocalDateTime dt) {
if (dt == null) return "";
return dt.format(DateTimeFormatter.ofPattern("MM-dd"));
}
}
@@ -0,0 +1,74 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.utils.MindMapNode;
import jakarta.annotation.PostConstruct;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
/**
* 远程 AI 思维导图生成客户端(预留实现)。
* <p>通过 {@code lpt.ai.endpoint} / {@code lpt.ai.api-key} 配置;未配置时不可用,
* 上层服务会回退到 {@link BuiltinMindMapGenerator}。</p>
*/
@Slf4j
@Component
@ConfigurationProperties(prefix = "lpt.ai")
@Setter
public class RemoteAiMindMapClient implements MindMapAiClient {
/** AI 服务端点 */
private String endpoint;
/** API Key */
private String apiKey;
/** 模型名称 */
private String model = "claude-fable-5";
/** 超时秒数 */
private int timeoutSeconds = 60;
private boolean enabled = false;
@PostConstruct
void init() {
enabled = apiKey != null && !apiKey.isBlank() && endpoint != null && !endpoint.isBlank();
if (enabled) {
log.info("RemoteAiMindMapClient 已启用: endpoint={}, model={}", endpoint, model);
} else {
log.info("RemoteAiMindMapClient 未配置,将使用内置生成器");
}
}
@Override
public boolean isAvailable() {
return enabled;
}
@Override
public String generatorName() {
return "AI-" + model;
}
@Override
public Optional<MindMapNode> generate(TaskEntity task,
List<StudyReportsEntity> reports,
List<StudyReportFragmentsEntity> fragments,
List<TaskApplicationEntity> applications,
String clientHint) {
if (!enabled) {
return Optional.empty();
}
// TODO: 调用远程 AI API,构造 prompt 发送 reports/fragments
// RestClient.create().post().uri(endpoint).body(prompt).retrieve()
log.warn("RemoteAiMindMapClient: 远程 AI 调用尚未实现,请配置 API Key 并实现请求逻辑");
return Optional.empty();
}
}
@@ -4,20 +4,15 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.CreateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpdateReviewApplicationRequest;
import com.guo.learningprogresstracker.dto.request.UpsertReviewMindMapRequest;
import com.guo.learningprogresstracker.entity.ReviewApplicationEntity;
import com.guo.learningprogresstracker.entity.ReviewMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.ReviewApplicationStatusEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapFileFormatEnum;
import com.guo.learningprogresstracker.enums.ReviewMindMapParseStatusEnum;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.ReviewApplicationMapper;
import com.guo.learningprogresstracker.mapper.ReviewMindMapMapper;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
@@ -60,7 +55,6 @@ public class ReviewServiceImpl implements ReviewService {
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final StudySessionsMapper studySessionsMapper;
private final TasksMapper tasksMapper;
private final ReviewApplicationMapper reviewApplicationMapper;
private final ReviewMindMapMapper reviewMindMapMapper;
private final MindMapFileParser mindMapFileParser = new MindMapFileParser(new ObjectMapper());
@@ -158,48 +152,6 @@ public class ReviewServiceImpl implements ReviewService {
.orElseThrow(() -> new NotFindEntitiesException("学习残片[" + id + "]不存在"));
}
@Override
public List<ReviewApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return reviewApplicationMapper.selectList(
Wrappers.<ReviewApplicationEntity>lambdaQuery()
.eq(ReviewApplicationEntity::getTaskNum, taskNum)
.orderByDesc(ReviewApplicationEntity::getLastModifiedTime)
.orderByDesc(ReviewApplicationEntity::getCreatedTime));
}
@Override
public ReviewApplicationEntity createApplication(CreateReviewApplicationRequest request) throws NotFindEntitiesException {
ensureTaskExists(request.getTaskNum());
ReviewApplicationEntity entity = new ReviewApplicationEntity();
entity.setTaskNum(request.getTaskNum());
entity.setTitle(request.getTitle());
entity.setDescription(request.getDescription());
entity.setResourceUrl(request.getResourceUrl());
entity.setStatus(normalizeApplicationStatus(request.getStatus()));
reviewApplicationMapper.insert(entity);
return entity;
}
@Override
public ReviewApplicationEntity updateApplication(Integer id, UpdateReviewApplicationRequest request) throws NotFindEntitiesException {
ReviewApplicationEntity existing = Optional.ofNullable(reviewApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
existing.setTitle(request.getTitle());
existing.setDescription(request.getDescription());
existing.setResourceUrl(request.getResourceUrl());
existing.setStatus(normalizeApplicationStatus(request.getStatus()));
reviewApplicationMapper.updateById(existing);
return existing;
}
@Override
public void deleteApplication(Integer id) throws NotFindEntitiesException {
ReviewApplicationEntity existing = Optional.ofNullable(reviewApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
reviewApplicationMapper.deleteById(existing.getId());
}
@Override
public ReviewMindMapEntity getMindMap(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
@@ -381,10 +333,6 @@ public class ReviewServiceImpl implements ReviewService {
}
}
private String normalizeApplicationStatus(String status) {
return ReviewApplicationStatusEnum.fromCodeOrDefault(status).getCode();
}
private String normalizeMindMapFormat(String contentFormat) {
if (!StringUtils.hasText(contentFormat)) {
return DEFAULT_MIND_MAP_FORMAT;
@@ -0,0 +1,359 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.entity.*;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import com.guo.learningprogresstracker.mapper.*;
import com.guo.learningprogresstracker.service.MindMapAiClient;
import com.guo.learningprogresstracker.service.StandardMindMapService;
import com.guo.learningprogresstracker.utils.CompareResult;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 标准思维导图服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StandardMindMapServiceImpl implements StandardMindMapService {
private final ReviewStandardMindMapMapper standardMindMapMapper;
private final ReviewRecallRecordMapper recallRecordMapper;
private final TasksMapper tasksMapper;
private final StudyReportsMapper studyReportsMapper;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
private final TaskApplicationMapper taskApplicationMapper;
private final StudySessionsMapper studySessionsMapper;
private final List<MindMapAiClient> aiClients;
private final ObjectMapper objectMapper;
private static final String GENERATOR_BUILTIN = "BUILTIN";
private static final String GENERATOR_USER = "USER";
private static final String MATCH_STATUS_MATCHED = "MATCHED";
private static final String MATCH_STATUS_MISSED = "MISSED";
// ============ 查询与生成 ============
@Override
public ReviewStandardMindMapEntity getOrGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
return existing != null ? existing : doGenerate(taskNum);
}
@Override
public ReviewStandardMindMapEntity regenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
return doGenerate(taskNum);
}
@Override
@Transactional
public ReviewStandardMindMapEntity updateByOutline(String taskNum, String outline) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
if (existing == null) {
existing = new ReviewStandardMindMapEntity();
existing.setTaskNum(taskNum);
}
// 解析大纲为树节点
MindMapNode root = MindMapTreeTool.parseOutline(outline);
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
existing.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
existing.setContent(contentJson);
existing.setOutline(outline);
existing.setGenerator(GENERATOR_USER);
existing.setGeneratorVersion(null);
existing.setSummary("用户编辑,共 " + MindMapTreeTool.countNodes(root) + " 个节点");
existing.setGeneratedTime(LocalDateTime.now());
if (existing.getId() == null) {
standardMindMapMapper.insert(existing);
} else {
standardMindMapMapper.updateById(existing);
}
return existing;
}
// ============ 回忆对比 ============
@Override
@Transactional
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline)
throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
// 1. 获取标准导图(自动生成)
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
// 2. 解析标准树和用户回忆树
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
// 3. 执行对比
CompareResult result = compareTrees(standardRoot, recallRoot);
// 4. 序列化对比结果
String resultJson;
try {
resultJson = objectMapper.writeValueAsString(result);
} catch (Exception e) {
throw new OperationFailedException("对比结果序列化失败");
}
// 5. 保存回忆记录
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
record.setTaskNum(taskNum);
record.setStandardMapId(standard.getId());
record.setRecallContent(recallOutline);
record.setCompareResult(resultJson);
record.setRecallRatio(result.getRecallRatio());
record.setMatchedCount(result.getMatchedCount());
record.setMissedCount(result.getMissedCount());
record.setExtraCount(result.getExtraCount());
recallRecordMapper.insert(record);
log.info("回忆对比: taskNum={}, recallRatio={}, matched={}, missed={}, extra={}",
taskNum, result.getRecallRatio(), result.getMatchedCount(),
result.getMissedCount(), result.getExtraCount());
return standard;
}
// ============ 对比算法核心 ============
/**
* 两颗树的节点级对比算法。
*/
CompareResult compareTrees(MindMapNode standardRoot, MindMapNode recallRoot) {
CompareResult result = new CompareResult();
// 展平标准树
List<MindMapNode> standardFlat = MindMapTreeTool.flatten(standardRoot);
// 展平回忆树(排除根节点本身)
List<MindMapNode> recallFlat = MindMapTreeTool.flatten(recallRoot).stream()
.filter(n -> n != recallRoot)
.collect(Collectors.toList());
// 构建回忆节点标题 → 节点映射(标准化后)
Map<String, MindMapNode> recallTitleMap = new LinkedHashMap<>();
for (MindMapNode node : recallFlat) {
recallTitleMap.merge(normalize(node.getTitle()), node, (a, b) -> a);
}
// 标注标准树
int matched = 0, missed = 0;
for (MindMapNode node : standardFlat) {
if (node == standardRoot) continue; // 跳过根节点
String key = normalize(node.getTitle());
boolean found = recallTitleMap.containsKey(key);
if (found) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
// 尝试模糊匹配
found = fuzzyMatch(node.getTitle(), recallTitleMap);
if (found) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
node.setNotes(MATCH_STATUS_MISSED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
missed++;
}
}
}
// 找出额外节点(用户在回忆中新增的、标准树中没有的)
Set<String> standardNormTitles = standardFlat.stream()
.map(n -> normalize(n.getTitle()))
.collect(Collectors.toSet());
List<CompareResult.FlatNode> extraNodes = new ArrayList<>();
for (MindMapNode node : recallFlat) {
String key = normalize(node.getTitle());
if (!standardNormTitles.contains(key)) {
CompareResult.FlatNode flat = new CompareResult.FlatNode();
flat.setTitle(node.getTitle());
flat.setPath(node.getTitle()); // 简化路径
extraNodes.add(flat);
}
}
int total = matched + missed;
result.setMatchedTree(standardRoot);
result.setExtraNodes(extraNodes);
result.setMatchedCount(matched);
result.setMissedCount(missed);
result.setExtraCount(extraNodes.size());
result.setRecallRatio(total > 0 ? (double) matched / total : 0);
return result;
}
// ============ 回忆记录查询 ============
@Override
public List<ReviewRecallRecordEntity> listRecallRecords(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return recallRecordMapper.selectList(
Wrappers.<ReviewRecallRecordEntity>lambdaQuery()
.eq(ReviewRecallRecordEntity::getTaskNum, taskNum)
.orderByDesc(ReviewRecallRecordEntity::getCreatedTime));
}
@Override
public ReviewRecallRecordEntity getRecallRecord(Integer recordId) throws NotFindEntitiesException {
return Optional.ofNullable(recallRecordMapper.selectById(recordId))
.orElseThrow(() -> new NotFindEntitiesException("回忆记录[" + recordId + "]不存在"));
}
// ============ 内部方法 ============
private ReviewStandardMindMapEntity doGenerate(String taskNum) throws OperationFailedException {
TaskEntity task = tasksMapper.selectOne(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"));
if (task == null) {
throw new OperationFailedException("任务[" + taskNum + "]不存在");
}
// 收集学习数据
List<String> sessionNums = studySessionsMapper.selectList(
Wrappers.<StudySessionsEntity>lambdaQuery()
.eq(StudySessionsEntity::getTaskNum, taskNum)
.select(StudySessionsEntity::getSessionNum))
.stream().map(StudySessionsEntity::getSessionNum).collect(Collectors.toList());
List<StudyReportsEntity> reports = sessionNums.isEmpty() ? List.of()
: studyReportsMapper.selectList(Wrappers.<StudyReportsEntity>lambdaQuery()
.in(StudyReportsEntity::getSessionNum, sessionNums));
List<StudyReportFragmentsEntity> fragments = sessionNums.isEmpty() ? List.of()
: studyReportFragmentsMapper.selectList(Wrappers.<StudyReportFragmentsEntity>lambdaQuery()
.in(StudyReportFragmentsEntity::getSessionNum, sessionNums));
List<TaskApplicationEntity> applications = taskApplicationMapper.selectList(
Wrappers.<TaskApplicationEntity>lambdaQuery().eq(TaskApplicationEntity::getTaskNum, taskNum));
if (reports.isEmpty() && fragments.isEmpty()) {
throw new OperationFailedException("任务[" + taskNum + "]没有学习报告或残片,无法生成思维导图");
}
// 找可用的 AI 客户端
MindMapAiClient client = aiClients.stream()
.filter(MindMapAiClient::isAvailable)
.findFirst()
.orElse(null);
if (client == null) {
throw new OperationFailedException("没有可用的思维导图生成器");
}
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
if (optRoot.isEmpty()) {
throw new OperationFailedException("思维导图生成失败");
}
MindMapNode root = optRoot.get();
String contentJson = MindMapTreeTool.toJson(root, objectMapper);
String outline = MindMapTreeTool.toFullOutline(root);
int nodeCount = MindMapTreeTool.countNodes(root);
int depth = MindMapTreeTool.maxDepth(root);
ReviewStandardMindMapEntity entity = queryByTaskNum(taskNum);
boolean create = entity == null;
if (create) {
entity = new ReviewStandardMindMapEntity();
entity.setTaskNum(taskNum);
}
entity.setTitle(root.getTitle() != null ? root.getTitle() : "思维导图");
entity.setContent(contentJson);
entity.setOutline(outline);
entity.setSummary("" + nodeCount + " 个节点,最大层级 " + depth);
entity.setGenerator(client.generatorName());
entity.setGeneratorVersion("1.0");
entity.setSourceReportCount(reports.size());
entity.setSourceFragmentCount(fragments.size());
entity.setGeneratedTime(LocalDateTime.now());
if (create) {
standardMindMapMapper.insert(entity);
} else {
standardMindMapMapper.updateById(entity);
}
return entity;
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
}
}
private ReviewStandardMindMapEntity queryByTaskNum(String taskNum) {
return standardMindMapMapper.selectOne(
Wrappers.<ReviewStandardMindMapEntity>lambdaQuery()
.eq(ReviewStandardMindMapEntity::getTaskNum, taskNum)
.last("LIMIT 1"));
}
// ============ 标题归一化 ============
/**
* 标准化标题用于对比:去空格、去标点、转小写。
*/
static String normalize(String s) {
if (s == null) return "";
// 先移除内部对比标记前缀,再清理标点
String result = s.replaceAll("^(?:MATCHED|MISSED)\\|", "");
result = result.replaceAll("[\\s 、,。!?:;()\\[\\]{},.!?:;()\\-—/\\\\|]", "");
return result.toLowerCase(Locale.ROOT).trim();
}
/**
* 模糊匹配:计算字符 bigram Jaccard 相似度
*/
static boolean fuzzyMatch(String title, Map<String, MindMapNode> recallTitleMap) {
if (title == null || title.isBlank()) return false;
String norm = normalize(title);
Set<String> bigrams = bigramSet(norm);
if (bigrams.isEmpty()) return false;
for (String recallKey : recallTitleMap.keySet()) {
Set<String> recallBigrams = bigramSet(recallKey);
if (recallBigrams.isEmpty()) continue;
// Jaccard
Set<String> intersection = new HashSet<>(bigrams);
intersection.retainAll(recallBigrams);
Set<String> union = new HashSet<>(bigrams);
union.addAll(recallBigrams);
double similarity = (double) intersection.size() / union.size();
if (similarity >= 0.6) {
return true;
}
}
return false;
}
static Set<String> bigramSet(String s) {
Set<String> set = new HashSet<>();
for (int i = 0; i < s.length() - 1; i++) {
set.add(s.substring(i, i + 2));
}
return set;
}
}
@@ -5,12 +5,18 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.guo.learningprogresstracker.dto.PriorityDto;
import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.enums.TaskApplicationStatusEnum;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapStruct.RequestConvert;
import com.guo.learningprogresstracker.mapStruct.TaskConvert;
import com.guo.learningprogresstracker.mapper.TaskApplicationMapper;
import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.utils.CalculatedPriorityTool;
@@ -18,6 +24,10 @@ import com.guo.learningprogresstracker.utils.GenerateNumTool;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Optional;
/**
* 针对表【tasks(存储学习任务的基本信息,包括优先级的多维度计算)】的数据库操作Service实现
@@ -30,6 +40,8 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
private final TasksMapper tasksMapper;
private final TaskApplicationMapper taskApplicationMapper;
@Override
public Page<TaskInfo> taskList(Integer pageNum, Integer pageSize) {
Page<TaskEntity> page = tasksMapper.selectPage(new Page<TaskEntity>(pageNum, pageSize),
@@ -86,4 +98,57 @@ public class TasksServiceImpl extends ServiceImpl<TasksMapper, TaskEntity>
}
this.removeById(taskId);
}
@Override
public List<TaskApplicationEntity> getApplications(String taskNum) throws NotFindEntitiesException {
ensureTaskExists(taskNum);
return taskApplicationMapper.selectList(
Wrappers.<TaskApplicationEntity>lambdaQuery()
.eq(TaskApplicationEntity::getTaskNum, taskNum)
.orderByDesc(TaskApplicationEntity::getLastModifiedTime)
.orderByDesc(TaskApplicationEntity::getCreatedTime));
}
@Override
public TaskApplicationEntity createApplication(CreateTaskApplicationRequest request) throws NotFindEntitiesException {
ensureTaskExists(request.getTaskNum());
TaskApplicationEntity entity = new TaskApplicationEntity();
entity.setTaskNum(request.getTaskNum());
entity.setTitle(request.getTitle());
entity.setDescription(request.getDescription());
entity.setResourceUrl(request.getResourceUrl());
entity.setStatus(normalizeApplicationStatus(request.getStatus()));
taskApplicationMapper.insert(entity);
return entity;
}
@Override
public TaskApplicationEntity updateApplication(Integer id, UpdateTaskApplicationRequest request) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
existing.setTitle(request.getTitle());
existing.setDescription(request.getDescription());
existing.setResourceUrl(request.getResourceUrl());
existing.setStatus(normalizeApplicationStatus(request.getStatus()));
taskApplicationMapper.updateById(existing);
return existing;
}
@Override
public void deleteApplication(Integer id) throws NotFindEntitiesException {
TaskApplicationEntity existing = Optional.ofNullable(taskApplicationMapper.selectById(id))
.orElseThrow(() -> new NotFindEntitiesException("应用场景[" + id + "]不存在"));
taskApplicationMapper.deleteById(existing.getId());
}
private void ensureTaskExists(String taskNum) throws NotFindEntitiesException {
if (!StringUtils.hasText(taskNum) || !tasksMapper.exists(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) {
throw new NotFindEntitiesException("任务[" + taskNum + "]不存在");
}
}
private String normalizeApplicationStatus(String status) {
return TaskApplicationStatusEnum.fromCodeOrDefault(status).getCode();
}
}
@@ -0,0 +1,44 @@
package com.guo.learningprogresstracker.utils;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* 思维导图结构对比结果。
* 每个 {@link NodeDiff} 对应标准导图中的一个节点,标注其匹配状态及用户在回忆中撰写的标题。
*/
@Data
public class CompareResult {
/** 对比后的完整树(带 MATCHED/MISSED 标注) */
private MindMapNode matchedTree;
/** 用户追加但标准导图中不存在的节点 */
private List<FlatNode> extraNodes = new ArrayList<>();
/** 回忆覆盖率 0-1 */
private double recallRatio;
/** 命中节点数 */
private int matchedCount;
/** 遗漏节点数 */
private int missedCount;
/** 额外节点数 */
private int extraCount;
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class FlatNode {
private String title;
private String path; // 以 / 分隔的路径
private String sourceType;
private Integer sourceId;
}
}
@@ -0,0 +1,95 @@
package com.guo.learningprogresstracker.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 思维导图树节点 DTO,与 MindMapFileParser 的输出结构兼容。
* 序列化后可在「nodes」和「children」两个 key 下放置子节点列表。
*
* <pre>{@code
* {
* "title": "根标题",
* "notes": "备注",
* "sourceType": "REPORT", // 可选:节点来源类型
* "sourceId": 1, // 可选:来源主键
* "children": [ ... ]
* }
* }</pre>
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class MindMapNode {
private String title;
private String notes;
private String sourceType;
private Integer sourceId;
private List<MindMapNode> children;
public MindMapNode(String title) {
this.title = title;
this.notes = "";
this.children = new ArrayList<>();
}
/** 构建 JSON 友好的 Map 结构(兼容 MindMapFileParser 输出风格) */
@SuppressWarnings("unchecked")
public Map<String, Object> toMap() {
Map<String, Object> map = new LinkedHashMap<>();
map.put("title", title != null ? title : "");
map.put("notes", notes != null ? notes : "");
if (sourceType != null) map.put("sourceType", sourceType);
if (sourceId != null) map.put("sourceId", sourceId);
List<Object> childMaps = new ArrayList<>();
if (children != null) {
for (MindMapNode c : children) {
childMaps.add(c.toMap());
}
}
map.put("children", childMaps);
return map;
}
/** 从 Map 重建节点 */
public static MindMapNode fromMap(Map<String, Object> map) {
MindMapNode node = new MindMapNode();
node.setTitle((String) map.getOrDefault("title", ""));
node.setNotes((String) map.getOrDefault("notes", ""));
node.setSourceType((String) map.get("sourceType"));
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
node.setSourceId(((Number) map.get("sourceId")).intValue());
}
Object raw = map.getOrDefault("children", map.get("nodes"));
List<MindMapNode> children = new ArrayList<>();
if (raw instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Map<?, ?> m) {
children.add(fromMap((Map<String, Object>) m));
}
}
}
node.setChildren(children);
return node;
}
/** 反序列化 JSON 字符串为 MindMapNode */
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
try {
Map<String, Object> map = mapper.readValue(json, LinkedHashMap.class);
return fromMap(map);
} catch (Exception e) {
return new MindMapNode("解析失败");
}
}
}
@@ -0,0 +1,172 @@
package com.guo.learningprogresstracker.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Getter;
import java.util.*;
/**
* 思维导图树结构与缩进大纲文本之间的双向转换工具。
* <p>大纲格式:每行一条节点,缩进表示层级(2 空格 / tab / # 前缀),行首 - * 数字. 等标记被自动剥离。</p>
*/
public class MindMapTreeTool {
private static final int INDENT_SPACES = 2;
private MindMapTreeTool() {
}
// ============ 序列化:树 → 缩进大纲 ============
/**
* 将根节点序列化为缩进大纲文本
*/
public static String toOutline(MindMapNode root) {
StringBuilder sb = new StringBuilder();
appendOutline(sb, root, 0);
return sb.toString();
}
private static void appendOutline(StringBuilder sb, MindMapNode node, int level) {
if (level > 0) {
sb.append(" ".repeat(level)).append("- ").append(node.getTitle() != null ? node.getTitle() : "").append('\n');
}
if (node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
appendOutline(sb, child, level + 1);
}
}
// level 0 是根节点标题本身不输出,但其子节点输出
if (level == 0 && node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
appendOutline(sb, child, 0);
}
}
}
/**
* 根节点序列化为完整大纲,第一行为根标题
*/
public static String toFullOutline(MindMapNode root) {
StringBuilder sb = new StringBuilder();
sb.append(root.getTitle() != null ? root.getTitle() : "").append('\n');
if (root.getChildren() != null) {
for (MindMapNode child : root.getChildren()) {
appendOutline(sb, child, 1);
}
}
return sb.toString();
}
// ============ 反序列化:缩进大纲 → 树 ============
/**
* 将缩进大纲文本解析为根节点。
* 第一行非空文本作为根标题,后续行作为子节点。
*/
public static MindMapNode parseOutline(String outlineText) {
if (outlineText == null || outlineText.isBlank()) {
return new MindMapNode("未命名");
}
String[] lines = outlineText.split("\\R");
List<String> nonBlank = new ArrayList<>();
for (String line : lines) {
if (!line.trim().isBlank()) {
nonBlank.add(line.stripTrailing());
}
}
if (nonBlank.isEmpty()) {
return new MindMapNode("未命名");
}
MindMapNode root = new MindMapNode(stripMarker(nonBlank.get(0).strip()));
List<MindMapNode> roots = new ArrayList<>();
Deque<StackEntry> stack = new ArrayDeque<>();
stack.push(new StackEntry(-1, roots));
for (int i = 1; i < nonBlank.size(); i++) {
String raw = nonBlank.get(i);
int level = detectLevel(raw);
String title = stripMarker(raw.strip());
MindMapNode child = new MindMapNode(title);
while (stack.peek().level >= level) {
stack.pop();
}
stack.peek().children.add(child);
List<MindMapNode> children = new ArrayList<>();
child.setChildren(children);
stack.push(new StackEntry(level, children));
}
root.setChildren(roots);
return root;
}
private static int detectLevel(String line) {
String trimmed = line.stripLeading();
int indent = line.length() - trimmed.length();
int hashCount = 0;
while (hashCount < trimmed.length() && trimmed.charAt(hashCount) == '#') {
hashCount++;
}
if (hashCount > 0) return hashCount;
if (indent == 0) return 1;
return Math.max(1, indent / INDENT_SPACES + 1);
}
private static String stripMarker(String s) {
return s.replaceFirst("^#{1,6}\\s*", "")
.replaceFirst("^[-*+]\\s*", "")
.replaceFirst("^\\d+\\.\\s*", "")
.strip();
}
private record StackEntry(int level, List<MindMapNode> children) {
}
// ============ 工具方法 ============
/** 展开树为平铺列表(前序遍历) */
public static List<MindMapNode> flatten(MindMapNode root) {
List<MindMapNode> list = new ArrayList<>();
flattenRecursive(root, list);
return list;
}
private static void flattenRecursive(MindMapNode node, List<MindMapNode> acc) {
acc.add(node);
if (node.getChildren() != null) {
for (MindMapNode child : node.getChildren()) {
flattenRecursive(child, acc);
}
}
}
/** 统计节点总数 */
public static int countNodes(MindMapNode root) {
return flatten(root).size();
}
/** 计算最大深度 */
public static int maxDepth(MindMapNode node) {
if (node.getChildren() == null || node.getChildren().isEmpty()) {
return 1;
}
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
}
/** 序列化整棵树为 JSON */
public static String toJson(MindMapNode root, ObjectMapper mapper) {
try {
return mapper.writeValueAsString(root.toMap());
} catch (Exception e) {
return "{\"title\":\"序列化失败\"}";
}
}
/** 从 JSON 反序列化 */
public static MindMapNode fromJson(String json, ObjectMapper mapper) {
return MindMapNode.fromJson(json, mapper);
}
}
+9 -1
View File
@@ -50,4 +50,12 @@ management:
endpoints:
web:
exposure:
include: health,info # 或 "*"
include: health,info # 或 "*"
# 思维导图 AI 配置(可选,未配置时使用内置生成器)
lpt:
ai:
endpoint:
api-key:
model: claude-fable-5
timeout-seconds: 60
@@ -0,0 +1,41 @@
create table review_sessions
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
target_title varchar(255) not null comment '本次复习的具体知识目标',
recall_content text null comment '用户主动回忆内容',
knowledge_network text null comment '用户重构出的体系化知识网络',
reflection text null comment '复习后的反思与缺口',
status varchar(30) not null default 'ONGOING' comment '状态:ONGOING/COMPLETED',
started_time datetime not null comment '开始时间',
completed_time datetime null comment '完成时间',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_sessions_task_num (task_num)
)
comment '复习模块:完整复习会话';
create table review_records
(
id int auto_increment comment 'id无业务含义'
primary key,
review_session_id int not null comment '复习会话ID',
source_type varchar(30) not null comment '关联来源:REPORT/FRAGMENT',
source_id int not null comment '来源ID',
recall_level varchar(30) not null default 'UNCERTAIN' comment '回忆程度:REMEMBERED/UNCERTAIN/FORGOTTEN',
note text null comment '对照后的说明',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_records_session_id (review_session_id),
index idx_review_records_source (source_type, source_id)
)
comment '复习模块:完整复习完成后与学习报告/残片的对照关系';
@@ -0,0 +1 @@
alter table review_applications rename to task_applications;
@@ -0,0 +1,2 @@
drop table if exists review_records;
drop table if exists review_sessions;
@@ -0,0 +1,45 @@
create table review_standard_mind_maps
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
title varchar(255) not null comment '标准思维导图标题',
content mediumtext not null comment '标准导图统一树结构JSON',
outline mediumtext null comment '标准导图缩进大纲文本(用于展示与编辑)',
summary text null comment '生成摘要',
generator varchar(30) not null default 'BUILTIN' comment '生成来源:BUILTIN/AI/USER',
generator_version varchar(64) null comment '生成器版本或AI模型名',
source_report_count int default 0 not null comment '生成时参考的学习报告数',
source_fragment_count int default 0 not null comment '生成时参考的学习残片数',
generated_time datetime null comment '最近生成时间',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
unique key uk_review_standard_mind_maps_task_num (task_num)
)
comment '复习模块:任务标准思维导图(由内置规则或AI从学习报告/残片生成,用户可修改)';
create table review_recall_records
(
id int auto_increment comment 'id无业务含义'
primary key,
task_num varchar(255) not null comment '任务编码',
standard_map_id int null comment '对比时使用的标准导图ID',
recall_content mediumtext not null comment '用户回忆绘制的导图大纲文本',
compare_result mediumtext null comment '与标准导图的结构对比结果JSON',
recall_ratio double null comment '回忆覆盖率(0-1',
matched_count int default 0 not null comment '回忆命中的节点数',
missed_count int default 0 not null comment '遗漏的节点数',
extra_count int default 0 not null comment '标准导图之外的新增节点数',
created_time datetime not null comment '创建时间',
created_by varchar(255) null,
last_modified_time datetime null,
last_modified_by varchar(255) null,
device_info varchar(50) null comment '操作者设备类型',
deleted int default 0 not null comment '逻辑删除符',
index idx_review_recall_records_task_num (task_num)
)
comment '复习模块:复习回忆与标准导图的对比记录';