fix(复习):AI 对比结果由 Java 端标注标准树,不再依赖 LLM 输出树结构

根因:LLM 无法可靠输出 CompareResult 所需的完整嵌套树 JSON,
实际返回格式(coverage/matched/missed)与预期不符。

修复:
- AiServiceClient.compareRecall() 返回 Optional<JsonNode>(原始 JSON),
  不再尝试直接反序列化为 CompareResult
- StandardMindMapServiceImpl 新增 buildCompareResultFromAI():
  解析 AI 返回的平铺 matches/missedTitles/extraNodes 列表,
  将 MATCHED|/MISSED| 标注写入标准树,确定性构建 CompareResult
- AI 只负责语义匹配判断,树结构操作由 Java 端保证可靠性
This commit is contained in:
2026-07-04 16:09:07 +08:00
parent 7ac7868511
commit af4f536980
2 changed files with 77 additions and 7 deletions
@@ -2,7 +2,6 @@ package com.guo.learningprogresstracker.service.impl;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.utils.CompareResult;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.Setter; import lombok.Setter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -133,10 +132,12 @@ public class AiServiceClient {
/** /**
* 调用 lpt-ai 进行语义化回忆对比。 * 调用 lpt-ai 进行语义化回忆对比。
* 注意:AI 返回平铺的匹配列表,不返回完整树结构。
* 树标注(MATCHED/MISSED)由 StandardMindMapServiceImpl 基于本方法返回的 JSON 完成。
* *
* @return 带 MATCHED/MISSED 标注和评价文本的对比结果;服务未配置或调用失败时返回 empty * @return AI 返回的原始 JSON;服务未配置或调用失败时返回 empty
*/ */
public Optional<CompareResult> compareRecall(String taskName, String standardOutline, String recallOutline) { public Optional<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
if (!isConfigured()) { if (!isConfigured()) {
return Optional.empty(); return Optional.empty();
} }
@@ -160,7 +161,7 @@ public class AiServiceClient {
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : ""); response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
return Optional.empty(); return Optional.empty();
} }
return Optional.of(objectMapper.readValue(response.body(), CompareResult.class)); return Optional.of(objectMapper.readTree(response.body()));
} catch (Exception e) { } catch (Exception e) {
log.warn("lpt-ai 回忆对比调用异常,将降级为内置算法: {}", e.getMessage()); log.warn("lpt-ai 回忆对比调用异常,将降级为内置算法: {}", e.getMessage());
return Optional.empty(); return Optional.empty();
@@ -1,6 +1,7 @@
package com.guo.learningprogresstracker.service.impl; package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.guo.learningprogresstracker.entity.*; import com.guo.learningprogresstracker.entity.*;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException; import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
@@ -112,13 +113,13 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
String taskName = tasksMapper.selectOne( String taskName = tasksMapper.selectOne(
Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1")) Wrappers.<TaskEntity>lambdaQuery().eq(TaskEntity::getTaskNum, taskNum).last("LIMIT 1"))
.getTaskName(); .getTaskName();
var optResult = aiServiceClient.compareRecall( var optJson = aiServiceClient.compareRecall(
taskName, taskName,
MindMapTreeTool.toFullOutline(standardRoot), MindMapTreeTool.toFullOutline(standardRoot),
recallOutline recallOutline
); );
if (optResult.isPresent()) { if (optJson.isPresent()) {
result = optResult.get(); result = buildCompareResultFromAI(optJson.get(), standardRoot);
log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}", log.info("AI 回忆对比: taskNum={}, recallRatio={}, evaluation={}",
taskNum, result.getRecallRatio(), taskNum, result.getRecallRatio(),
result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : ""); result.getEvaluation() != null ? result.getEvaluation().substring(0, Math.min(50, result.getEvaluation().length())) : "");
@@ -225,6 +226,74 @@ public class StandardMindMapServiceImpl implements StandardMindMapService {
return result; 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();
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 @Override