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 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 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.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 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 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 standardFlat = MindMapTreeTool.flatten(standardRoot); // 展平回忆树(排除根节点本身) List recallFlat = MindMapTreeTool.flatten(recallRoot).stream() .filter(n -> n != recallRoot) .collect(Collectors.toList()); // 构建回忆节点标题 → 节点映射(标准化后) Map 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 standardNormTitles = standardFlat.stream() .map(n -> normalize(n.getTitle())) .collect(Collectors.toSet()); List 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 标注回标准树。 *

AI 只输出哪些节点匹配/遗漏,树结构标注由本方法确定性完成,避免 LLM 输出不可靠的嵌套 JSON。

*/ private CompareResult buildCompareResultFromAI(JsonNode aiJson, MindMapNode standardRoot) { CompareResult result = new CompareResult(); // 1. 读取 AI 返回的匹配对 → 构建 standardNorm → matchFlag Set 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 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 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 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 listRecallRecords(String taskNum) throws NotFindEntitiesException { ensureTaskExists(taskNum); return recallRecordMapper.selectList( Wrappers.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.lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1")); if (task == null) { throw new OperationFailedException("任务[" + taskNum + "]不存在"); } // 收集学习数据 List sessionNums = studySessionsMapper.selectList( Wrappers.lambdaQuery() .eq(StudySessionsEntity::getTaskNum, taskNum) .select(StudySessionsEntity::getSessionNum)) .stream().map(StudySessionsEntity::getSessionNum).collect(Collectors.toList()); List reports = sessionNums.isEmpty() ? List.of() : studyReportsMapper.selectList(Wrappers.lambdaQuery() .in(StudyReportsEntity::getSessionNum, sessionNums)); List fragments = sessionNums.isEmpty() ? List.of() : studyReportFragmentsMapper.selectList(Wrappers.lambdaQuery() .in(StudyReportFragmentsEntity::getSessionNum, sessionNums)); List applications = taskApplicationMapper.selectList( Wrappers.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 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.lambdaQuery().eq(TaskEntity::getTaskNum, taskNum))) { throw new NotFindEntitiesException("任务[" + taskNum + "]不存在"); } } private ReviewStandardMindMapEntity queryByTaskNum(String taskNum) { return standardMindMapMapper.selectOne( Wrappers.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 recallTitleMap) { if (title == null || title.isBlank()) return false; String norm = normalize(title); Set bigrams = bigramSet(norm); if (bigrams.isEmpty()) return false; for (String recallKey : recallTitleMap.keySet()) { Set recallBigrams = bigramSet(recallKey); if (recallBigrams.isEmpty()) continue; // Jaccard Set intersection = new HashSet<>(bigrams); intersection.retainAll(recallBigrams); Set union = new HashSet<>(bigrams); union.addAll(recallBigrams); double similarity = (double) intersection.size() / union.size(); if (similarity >= 0.6) { return true; } } return false; } static Set bigramSet(String s) { Set set = new HashSet<>(); for (int i = 0; i < s.length() - 1; i++) { set.add(s.substring(i, i + 2)); } return set; } }