feat(复习): 支持会话维度复习 + 标准导图按会话过滤(后端)
- MindMapNode 新增 sessionNum 字段
- BuiltinMindMapGenerator 生成节点时填充 sessionNum
- MindMapTreeTool.filterBySession() 按会话过滤标准导图
- recallCompare 接受 sessionNum 参数
- ReviewRecallRecordEntity 新增 sessionNum 字段
- Flyway V20260705_1: review_recall_records 加 session_num 列
- 新增 GET /study-sessions/tasks/{taskNum}/sessions 端点
This commit is contained in:
@@ -154,7 +154,7 @@ public class ReviewController {
|
|||||||
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
|
public CommonResult<ReviewStandardMindMapEntity> recallCompare(
|
||||||
@PathVariable String taskNum,
|
@PathVariable String taskNum,
|
||||||
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
|
@Valid @RequestBody RecallCompareRequest request) throws NotFindEntitiesException, OperationFailedException {
|
||||||
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline()));
|
return CommonResult.success(standardMindMapService.recallCompare(taskNum, request.getRecallOutline(), request.getSessionNum()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,4 +139,10 @@ public class StudySessionController {
|
|||||||
@RequestParam(required = false) String excludeTaskNum) {
|
@RequestParam(required = false) String excludeTaskNum) {
|
||||||
return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum));
|
return CommonResult.success(studySessionsServiceImpl.getActiveSession(excludeTaskNum));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取任务的所有会话简要列表(用于复习维度选择) */
|
||||||
|
@GetMapping("/tasks/{taskNum}/sessions")
|
||||||
|
public CommonResult<List<SessionBriefVO>> getTaskSessions(@PathVariable String taskNum) {
|
||||||
|
return CommonResult.success(studySessionsServiceImpl.getTaskSessions(taskNum));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ public class RecallCompareRequest {
|
|||||||
|
|
||||||
@NotBlank(message = "回忆大纲不可为空")
|
@NotBlank(message = "回忆大纲不可为空")
|
||||||
private String recallOutline;
|
private String recallOutline;
|
||||||
|
|
||||||
|
/** 复习维度:会话编码,null 为任务维度 */
|
||||||
|
private String sessionNum;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.guo.learningprogresstracker.dto.response;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话简要信息(用于复习维度选择列表)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SessionBriefVO {
|
||||||
|
private String sessionNum;
|
||||||
|
private String startTime;
|
||||||
|
private String expectation;
|
||||||
|
private int reportCount;
|
||||||
|
private int fragmentCount;
|
||||||
|
}
|
||||||
@@ -24,6 +24,12 @@ public class ReviewRecallRecordEntity extends BaseEntity implements Serializable
|
|||||||
@TableField(value = "standard_map_id")
|
@TableField(value = "standard_map_id")
|
||||||
private Integer standardMapId;
|
private Integer standardMapId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复习维度:会话编码(null 表示任务维度复习)
|
||||||
|
*/
|
||||||
|
@TableField(value = "session_num")
|
||||||
|
private String sessionNum;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户回忆绘制的导图大纲文本
|
* 用户回忆绘制的导图大纲文本
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ public interface StandardMindMapService {
|
|||||||
* 用户提交回忆大纲,与标准导图对比并返回结果。
|
* 用户提交回忆大纲,与标准导图对比并返回结果。
|
||||||
*
|
*
|
||||||
* @param recallOutline 用户回忆的缩进大纲文本
|
* @param recallOutline 用户回忆的缩进大纲文本
|
||||||
|
* @param sessionNum 可选会话编码,指定时仅对比该会话相关的标准导图节点
|
||||||
* @return 对比记录实体(compareResult 字段包含对比树 JSON)
|
* @return 对比记录实体(compareResult 字段包含对比树 JSON)
|
||||||
*/
|
*/
|
||||||
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline) throws NotFindEntitiesException, OperationFailedException;
|
ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum) throws NotFindEntitiesException, OperationFailedException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取该任务的所有回忆对比记录。
|
* 获取该任务的所有回忆对比记录。
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
import com.guo.learningprogresstracker.exception.ErrorParameterException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author guo
|
* @author guo
|
||||||
@@ -35,4 +37,7 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
|
|||||||
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
|
Page<StudyReportsEntity> getTaskReports(String taskNum, int page, int size, String keyword);
|
||||||
|
|
||||||
StudySessionResponse getActiveSession(String excludeTaskNum);
|
StudySessionResponse getActiveSession(String excludeTaskNum);
|
||||||
|
|
||||||
|
/** 获取任务的所有会话简要信息(复习维度选择用) */
|
||||||
|
List<SessionBriefVO> getTaskSessions(String taskNum);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -84,6 +84,7 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
MindMapNode sessionNode = new MindMapNode(sessionTitle);
|
||||||
|
sessionNode.setSessionNum(sessionNum);
|
||||||
|
|
||||||
// 报告作为子节点
|
// 报告作为子节点
|
||||||
for (StudyReportsEntity report : sessReports) {
|
for (StudyReportsEntity report : sessReports) {
|
||||||
@@ -93,6 +94,7 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
reportNode.setNotes(content);
|
reportNode.setNotes(content);
|
||||||
reportNode.setSourceType("REPORT");
|
reportNode.setSourceType("REPORT");
|
||||||
reportNode.setSourceId(report.getId());
|
reportNode.setSourceId(report.getId());
|
||||||
|
reportNode.setSessionNum(sessionNum);
|
||||||
sessionNode.getChildren().add(reportNode);
|
sessionNode.getChildren().add(reportNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +106,7 @@ public class BuiltinMindMapGenerator implements MindMapAiClient {
|
|||||||
fragNode.setNotes(content);
|
fragNode.setNotes(content);
|
||||||
fragNode.setSourceType("FRAGMENT");
|
fragNode.setSourceType("FRAGMENT");
|
||||||
fragNode.setSourceId(frag.getId());
|
fragNode.setSourceId(frag.getId());
|
||||||
|
fragNode.setSessionNum(sessionNum);
|
||||||
sessionNode.getChildren().add(fragNode);
|
sessionNode.getChildren().add(fragNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -96,7 +96,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline)
|
public ReviewStandardMindMapEntity recallCompare(String taskNum, String recallOutline, String sessionNum)
|
||||||
throws NotFindEntitiesException, OperationFailedException {
|
throws NotFindEntitiesException, OperationFailedException {
|
||||||
ensureTaskExists(taskNum);
|
ensureTaskExists(taskNum);
|
||||||
|
|
||||||
@@ -107,6 +107,11 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
|
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
|
||||||
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
|
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
|
||||||
|
|
||||||
|
// 2b. 若指定了会话维度,过滤标准导图只保留该会话相关节点
|
||||||
|
if (sessionNum != null && !sessionNum.isBlank()) {
|
||||||
|
standardRoot = MindMapTreeTool.filterBySession(standardRoot, sessionNum);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
|
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
|
||||||
CompareResult result;
|
CompareResult result;
|
||||||
if (aiServiceClient.isConfigured()) {
|
if (aiServiceClient.isConfigured()) {
|
||||||
@@ -142,6 +147,7 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
|
|||||||
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
|
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
|
||||||
record.setTaskNum(taskNum);
|
record.setTaskNum(taskNum);
|
||||||
record.setStandardMapId(standard.getId());
|
record.setStandardMapId(standard.getId());
|
||||||
|
record.setSessionNum(sessionNum != null && !sessionNum.isBlank() ? sessionNum : null);
|
||||||
record.setRecallContent(recallOutline);
|
record.setRecallContent(recallOutline);
|
||||||
record.setCompareResult(resultJson);
|
record.setCompareResult(resultJson);
|
||||||
record.setRecallRatio(result.getRecallRatio());
|
record.setRecallRatio(result.getRecallRatio());
|
||||||
|
|||||||
+30
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.google.protobuf.ServiceException;
|
import com.google.protobuf.ServiceException;
|
||||||
import com.guo.learningprogresstracker.dto.StudySessionsDto;
|
import com.guo.learningprogresstracker.dto.StudySessionsDto;
|
||||||
|
import com.guo.learningprogresstracker.dto.response.SessionBriefVO;
|
||||||
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
|
||||||
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
|
||||||
@@ -26,6 +27,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -225,6 +227,34 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 获取任务会话列表(复习维度选择用) ============
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<SessionBriefVO> getTaskSessions(String taskNum) {
|
||||||
|
var sessions = this.list(Wrappers.lambdaQuery(StudySessionsEntity.class)
|
||||||
|
.eq(StudySessionsEntity::getTaskNum, taskNum)
|
||||||
|
.orderByDesc(StudySessionsEntity::getStartTime));
|
||||||
|
|
||||||
|
return sessions.stream().map(s -> {
|
||||||
|
SessionBriefVO vo = new SessionBriefVO();
|
||||||
|
vo.setSessionNum(s.getSessionNum());
|
||||||
|
vo.setStartTime(s.getStartTime() != null ? s.getStartTime().toString() : null);
|
||||||
|
// 查询该会话的预期
|
||||||
|
Optional.ofNullable(studyExpectationsService.getBySessionNum(s.getSessionNum()))
|
||||||
|
.ifPresent(e -> vo.setExpectation(e.getDescription()));
|
||||||
|
// 统计报告和残片数量
|
||||||
|
long reportCount = studyReportsMapper.selectCount(
|
||||||
|
Wrappers.lambdaQuery(StudyReportsEntity.class)
|
||||||
|
.eq(StudyReportsEntity::getSessionNum, s.getSessionNum()));
|
||||||
|
long fragmentCount = studyReportFragmentsMapper.selectCount(
|
||||||
|
Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
|
||||||
|
.eq(StudyReportFragmentsEntity::getSessionNum, s.getSessionNum()));
|
||||||
|
vo.setReportCount((int) reportCount);
|
||||||
|
vo.setFragmentCount((int) fragmentCount);
|
||||||
|
return vo;
|
||||||
|
}).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
// ============ 活跃会话查询 ============
|
// ============ 活跃会话查询 ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public class MindMapNode {
|
|||||||
private String notes;
|
private String notes;
|
||||||
private String sourceType;
|
private String sourceType;
|
||||||
private Integer sourceId;
|
private Integer sourceId;
|
||||||
|
/** 可选:节点来源会话编码(REPORT/FRAGMENT 节点使用) */
|
||||||
|
private String sessionNum;
|
||||||
private List<MindMapNode> children;
|
private List<MindMapNode> children;
|
||||||
|
|
||||||
public MindMapNode(String title) {
|
public MindMapNode(String title) {
|
||||||
@@ -51,6 +53,7 @@ public class MindMapNode {
|
|||||||
map.put("notes", notes != null ? notes : "");
|
map.put("notes", notes != null ? notes : "");
|
||||||
if (sourceType != null) map.put("sourceType", sourceType);
|
if (sourceType != null) map.put("sourceType", sourceType);
|
||||||
if (sourceId != null) map.put("sourceId", sourceId);
|
if (sourceId != null) map.put("sourceId", sourceId);
|
||||||
|
if (sessionNum != null) map.put("sessionNum", sessionNum);
|
||||||
List<Object> childMaps = new ArrayList<>();
|
List<Object> childMaps = new ArrayList<>();
|
||||||
if (children != null) {
|
if (children != null) {
|
||||||
for (MindMapNode c : children) {
|
for (MindMapNode c : children) {
|
||||||
@@ -70,6 +73,7 @@ public class MindMapNode {
|
|||||||
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
|
if (map.containsKey("sourceId") && map.get("sourceId") != null) {
|
||||||
node.setSourceId(((Number) map.get("sourceId")).intValue());
|
node.setSourceId(((Number) map.get("sourceId")).intValue());
|
||||||
}
|
}
|
||||||
|
node.setSessionNum((String) map.get("sessionNum"));
|
||||||
Object raw = map.getOrDefault("children", map.get("nodes"));
|
Object raw = map.getOrDefault("children", map.get("nodes"));
|
||||||
List<MindMapNode> children = new ArrayList<>();
|
List<MindMapNode> children = new ArrayList<>();
|
||||||
if (raw instanceof List<?> list) {
|
if (raw instanceof List<?> list) {
|
||||||
|
|||||||
@@ -156,6 +156,61 @@ public class MindMapTreeTool {
|
|||||||
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
|
return 1 + node.getChildren().stream().mapToInt(MindMapTreeTool::maxDepth).max().orElse(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤树:只保留与指定 sessionNum 相关的分支。
|
||||||
|
* - 节点有 sessionNum 且匹配 → 保留
|
||||||
|
* - 节点无 sessionNum(如 APPLICATION 或 AI 生成的节点) → 保留(视为任务级通用知识)
|
||||||
|
* - 有子节点被保留时,父节点也保留
|
||||||
|
*
|
||||||
|
* @param root 树根
|
||||||
|
* @param sessionNum 目标会话编码
|
||||||
|
* @return 过滤后的树,若无相关节点则返回仅含根的空树
|
||||||
|
*/
|
||||||
|
public static MindMapNode filterBySession(MindMapNode root, String sessionNum) {
|
||||||
|
if (root == null || sessionNum == null || sessionNum.isBlank()) return root;
|
||||||
|
MindMapNode filtered = filterRecursive(root, sessionNum);
|
||||||
|
return filtered != null ? filtered : new MindMapNode(root.getTitle() != null ? root.getTitle() : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MindMapNode filterRecursive(MindMapNode node, String sessionNum) {
|
||||||
|
if (node == null) return null;
|
||||||
|
|
||||||
|
// 先过滤子节点
|
||||||
|
List<MindMapNode> keptChildren = new ArrayList<>();
|
||||||
|
if (node.getChildren() != null) {
|
||||||
|
for (MindMapNode child : node.getChildren()) {
|
||||||
|
MindMapNode kept = filterRecursive(child, sessionNum);
|
||||||
|
if (kept != null) {
|
||||||
|
keptChildren.add(kept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断当前节点是否应该保留:
|
||||||
|
// 1. 有 sessionNum 且匹配 → 保留
|
||||||
|
// 2. 无 sessionNum → 保留(任务级节点,如 APPLICATION 或 AI 生成)
|
||||||
|
// 3. 有子节点保留 → 保留
|
||||||
|
boolean selfMatch = (node.getSessionNum() == null) || sessionNum.equals(node.getSessionNum());
|
||||||
|
if (selfMatch || !keptChildren.isEmpty()) {
|
||||||
|
MindMapNode result = copyNodeShallow(node);
|
||||||
|
result.setChildren(keptChildren.isEmpty() ? node.getChildren() : keptChildren);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 浅拷贝节点(仅拷贝标量字段,不拷贝 children) */
|
||||||
|
private static MindMapNode copyNodeShallow(MindMapNode node) {
|
||||||
|
MindMapNode copy = new MindMapNode();
|
||||||
|
copy.setTitle(node.getTitle());
|
||||||
|
copy.setNotes(node.getNotes());
|
||||||
|
copy.setSourceType(node.getSourceType());
|
||||||
|
copy.setSourceId(node.getSourceId());
|
||||||
|
copy.setSessionNum(node.getSessionNum());
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
/** 序列化整棵树为 JSON */
|
/** 序列化整棵树为 JSON */
|
||||||
public static String toJson(MindMapNode root, ObjectMapper mapper) {
|
public static String toJson(MindMapNode root, ObjectMapper mapper) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 为回忆对比记录添加 session_num 字段,支持会话维度的复习过滤
|
||||||
|
ALTER TABLE review_recall_records
|
||||||
|
ADD COLUMN session_num VARCHAR(255) NULL AFTER standard_map_id;
|
||||||
|
|
||||||
|
CREATE INDEX idx_recall_records_session_num ON review_recall_records (session_num);
|
||||||
Reference in New Issue
Block a user