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:
2026-07-05 14:40:38 +08:00
parent 22cc5dc871
commit a636e516d1
13 changed files with 144 additions and 3 deletions
@@ -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);
} }
@@ -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);
} }
@@ -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());
@@ -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);