Files
lpt-be/src/main/java/com/guo/learningprogresstracker/service/impl/StandardMindMapServiceImpl.java
T
cat-shark b18f8b4d1c feat(复习): 节点级 focusPath + findNode,回退 session 维度
- 移除 MindMapNode.sessionNum、filterBySession、getTaskSessions
- 新增 MindMapTreeTool.extractSubtree() 按路径提取子树作为对比基准
- 新增 findClosestNode/getPath/similarityScore 支持节点匹配
- 新增 POST /review/standard-mind-map/{taskNum}/find-node
- recallCompare 使用 focusPath 替代 sessionNum
- ReviewRecallRecordEntity.focusPath 替代 sessionNum
- Flyway V20260706_1: review_recall_records 加 focus_path 列
2026-07-05 18:12:32 +08:00

557 lines
24 KiB
Java

package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.JsonNode;
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.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
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 final AiServiceClient aiServiceClient;
private static final String GENERATOR_BUILTIN = "BUILTIN";
private static final String GENERATOR_USER = "USER";
private static final String GENERATOR_USER_MERGE = "USER_MERGE";
private static final String MATCH_STATUS_MATCHED = "MATCHED";
private static final String MATCH_STATUS_MISSED = "MISSED";
/** 防并发生成:taskNum → 是否正在生成中 */
private final ConcurrentHashMap<String, AtomicBoolean> generatingTasks = new ConcurrentHashMap<>();
// ============ 查询与生成 ============
@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);
// 防并发生成:同一任务正在生成时直接返回当前实体
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
if (!lock.compareAndSet(false, true)) {
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
log.warn("任务[{}]正在生成中,跳过重复请求", taskNum);
return existing;
}
try {
return doGenerate(taskNum);
} finally {
lock.set(false);
generatingTasks.remove(taskNum);
}
}
@Override
@Transactional
public ReviewStandardMindMapEntity incrementalGenerate(String taskNum) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity existing = queryByTaskNum(taskNum);
if (existing == null) {
throw new NotFindEntitiesException("标准思维导图尚不存在,无法增量更新");
}
// 防并发生成
AtomicBoolean lock = generatingTasks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
if (!lock.compareAndSet(false, true)) {
log.warn("任务[{}]正在生成中,跳过重复增量请求", taskNum);
return existing;
}
try {
ReviewStandardMindMapEntity fresh = doGenerate(taskNum);
// 合并新旧树:保留用户编辑过的节点,追加新节点
MindMapNode oldRoot = MindMapTreeTool.fromJson(existing.getContent(), objectMapper);
MindMapNode newRoot = MindMapTreeTool.fromJson(fresh.getContent(), objectMapper);
MindMapNode merged = MindMapTreeTool.mergeTrees(oldRoot, newRoot);
String mergedJson = MindMapTreeTool.toJson(merged, objectMapper);
String mergedOutline = MindMapTreeTool.toFullOutline(merged);
existing.setContent(mergedJson);
existing.setOutline(mergedOutline);
existing.setTitle(merged.getTitle() != null ? merged.getTitle() : "思维导图");
existing.setGenerator(GENERATOR_USER_MERGE);
existing.setGeneratorVersion(fresh.getGeneratorVersion());
existing.setSummary("增量更新,共 " + MindMapTreeTool.countNodes(merged) + " 个节点");
existing.setGeneratedTime(LocalDateTime.now());
standardMindMapMapper.updateById(existing);
return existing;
} finally {
lock.set(false);
generatingTasks.remove(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, String focusPath)
throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
// 1. 获取标准导图(自动生成)
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
// 2. 解析标准树和用户回忆树
MindMapNode standardRoot = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode recallRoot = MindMapTreeTool.parseOutline(recallOutline);
// 2b. 若指定了起点节点路径,提取该子树作为对比基准
MindMapNode compareRoot = standardRoot;
if (focusPath != null && !focusPath.isBlank()) {
compareRoot = MindMapTreeTool.extractSubtree(standardRoot, focusPath);
if (compareRoot == null) {
log.warn("focusPath 未匹配到节点,使用全量标准导图: path={}", focusPath);
compareRoot = standardRoot;
}
}
// 3. 执行对比(优先 AI 语义对比,失败时降级为内置算法)
CompareResult result;
if (aiServiceClient.isConfigured()) {
String taskName = tasksMapper.selectOne(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"))
.getTaskName();
var optJson = aiServiceClient.compareRecall(
taskName,
MindMapTreeTool.toFullOutline(compareRoot),
recallOutline
);
if (optJson.isPresent()) {
result = buildCompareResultFromAI(optJson.get(), compareRoot);
log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}",
taskNum, result.getRecallRatio(),
result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : "");
} else {
result = compareTrees(compareRoot, recallRoot);
}
} else {
result = compareTrees(compareRoot, 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.setFocusPath(focusPath != null && !focusPath.isBlank() ? focusPath : null);
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;
}
// ============ 节点查找 ============
@Override
public Map<String, Object> findNode(String taskNum, String content) throws NotFindEntitiesException, OperationFailedException {
ensureTaskExists(taskNum);
ReviewStandardMindMapEntity standard = getOrGenerate(taskNum);
MindMapNode root = MindMapTreeTool.fromJson(standard.getContent(), objectMapper);
MindMapNode closest = MindMapTreeTool.findClosestNode(root, content);
Map<String, Object> result = new LinkedHashMap<>();
if (closest != null) {
String path = MindMapTreeTool.getPath(root, closest.getTitle());
result.put("path", path);
result.put("nodeTitle", closest.getTitle());
result.put("score", MindMapTreeTool.similarityScore(closest.getTitle(), content));
} else {
result.put("path", "");
result.put("nodeTitle", "");
result.put("score", 0.0);
}
return result;
}
// ============ 对比算法核心 ============
/**
* 两颗树的节点级对比算法。
*/
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;
}
/**
* 从 AI 返回的平铺匹配列表构建 CompareResult,并将 MATCHED/MISSED 标注回标准树。
* <p>AI 只输出哪些节点匹配/遗漏,树结构标注由本方法确定性完成,避免 LLM 输出不可靠的嵌套 JSON。</p>
*/
private CompareResult buildCompareResultFromAI(JsonNode aiJson, MindMapNode standardRoot) {
CompareResult result = new CompareResult();
// 1. 读取 AI 返回的匹配对 → 构建 standardNorm → matchFlag
Set<String> matchedStandardTitles = new HashSet<>();
JsonNode matchesArr = aiJson.path("matches");
if (matchesArr.isArray()) {
for (JsonNode m : matchesArr) {
String stdTitle = m.path("standardTitle").asText(null);
if (stdTitle != null) {
matchedStandardTitles.add(normalize(stdTitle));
}
}
}
// 2. 读取遗漏列表
Set<String> missedTitles = new HashSet<>();
JsonNode missedArr = aiJson.path("missedTitles");
if (missedArr.isArray()) {
for (JsonNode t : missedArr) {
String title = t.asText(null);
if (title != null) missedTitles.add(normalize(title));
}
}
// 3. 标注标准树的每个非根节点
List<MindMapNode> standardFlat = MindMapTreeTool.flatten(standardRoot);
int matched = 0, missed = 0;
for (MindMapNode node : standardFlat) {
if (node == standardRoot) continue;
String key = normalize(node.getTitle());
if (matchedStandardTitles.contains(key)) {
node.setNotes(MATCH_STATUS_MATCHED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
matched++;
} else {
node.setNotes(MATCH_STATUS_MISSED + "|" + (node.getNotes() != null ? node.getNotes() : ""));
missed++;
}
}
// 4. 读取 extraNodes(兼容字符串数组和对象数组两种格式)
List<CompareResult.FlatNode> extras = new ArrayList<>();
JsonNode extrasArr = aiJson.path("extraNodes");
if (extrasArr.isArray()) {
for (JsonNode e : extrasArr) {
CompareResult.FlatNode fn = new CompareResult.FlatNode();
if (e.isTextual()) {
// 字符串格式 ["知识点D1", "知识点D2"]
fn.setTitle(e.asText(""));
fn.setPath("");
} else {
// 对象格式 [{ title: "...", path: "..." }]
fn.setTitle(e.path("title").asText(""));
fn.setPath(e.path("path").asText(""));
}
extras.add(fn);
}
}
// 5. 组装结果
result.setMatchedTree(standardRoot);
result.setExtraNodes(extras);
result.setMatchedCount(matched);
result.setMissedCount(missed);
result.setExtraCount(extras.size());
int total = matched + missed;
result.setRecallRatio(total > 0 ? (double) matched / total : 0);
result.setEvaluation(aiJson.path("evaluation").asText(null));
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 客户端(非 BUILTIN),其次内置生成器
MindMapAiClient client = aiClients.stream()
.filter(MindMapAiClient::isAvailable)
.min(Comparator.comparing(c -> "BUILTIN".equals(c.generatorName()) ? 1 : 0))
.orElse(null);
if (client == null) {
throw new OperationFailedException("没有可用的思维导图生成器");
}
Optional<MindMapNode> optRoot = client.generate(task, reports, fragments, applications, null);
// AI 生成失败时尝试降级到内置生成器
if (optRoot.isEmpty() && !"BUILTIN".equals(client.generatorName())) {
log.info("AI 思维导图生成失败,降级到内置生成器");
MindMapAiClient fallback = aiClients.stream()
.filter(c -> "BUILTIN".equals(c.generatorName()) && c.isAvailable())
.findFirst().orElse(null);
if (fallback != null) {
optRoot = fallback.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;
}
}