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);
}
}