metadata = buildDocumentMetadata(docId, chunk, chunks.size(), category);
@@ -280,6 +280,30 @@ public class VectorIndexService {
return metadata;
}
+ static String buildEmbeddingText(DocumentChunk chunk) {
+ String content = trimToEmpty(chunk.getContent());
+ String title = trimToEmpty(chunk.getTitle());
+ String breadcrumb = trimToEmpty(chunk.getBreadcrumb());
+
+ if (title.isEmpty() && breadcrumb.isEmpty()) {
+ return content;
+ }
+
+ StringBuilder text = new StringBuilder();
+ if (!title.isEmpty()) {
+ text.append("Title: ").append(title).append("\n");
+ }
+ if (!breadcrumb.isEmpty()) {
+ text.append("Path: ").append(breadcrumb).append("\n");
+ }
+ text.append("Content:\n").append(content);
+ return text.toString();
+ }
+
+ private static String trimToEmpty(String value) {
+ return value == null ? "" : value.trim();
+ }
+
/**
* 删除文件的旧数据(根据 metadata._source)
*/
diff --git a/src/main/java/com/superbiz/agent/service/VectorSearchService.java b/src/main/java/com/superbiz/agent/service/VectorSearchService.java
index e9baee7..4b49a49 100644
--- a/src/main/java/com/superbiz/agent/service/VectorSearchService.java
+++ b/src/main/java/com/superbiz/agent/service/VectorSearchService.java
@@ -1,5 +1,8 @@
package com.superbiz.agent.service;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.superbiz.agent.constant.MilvusConstants;
import io.milvus.client.MilvusServiceClient;
import io.milvus.grpc.SearchResults;
import io.milvus.param.R;
@@ -7,19 +10,26 @@ import io.milvus.param.dml.SearchParam;
import io.milvus.response.SearchResultsWrapper;
import lombok.Getter;
import lombok.Setter;
-import com.superbiz.agent.constant.MilvusConstants;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.ai.document.Document;
+import org.springframework.ai.vectorstore.SearchRequest;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
/**
- * 向量搜索服务
- * 负责从 Milvus 中搜索相似向量
+ * Vector retrieval facade used by lookup_knowledge.
+ *
+ * The public API stays stable while the implementation can route to Spring AI
+ * VectorStore, the original Milvus SDK path, or automatic fallback.
*/
@Service
public class VectorSearchService {
@@ -32,34 +42,84 @@ public class VectorSearchService {
@Autowired
private VectorEmbeddingService embeddingService;
- /**
- * 搜索相似文档
- *
- * @param query 查询文本
- * @param topK 返回最相似的K个结果
- * @return 搜索结果列表
- */
+ @Autowired
+ private ObjectProvider vectorStoreProvider;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Value("${retrieval.vector-store.mode:auto}")
+ private String vectorStoreMode = "auto";
+
+ @Value("${retrieval.normalization.max-l2-distance:2.0}")
+ private double maxL2Distance = 2.0;
+
public List searchSimilarDocuments(String query, int topK) {
return searchSimilarDocuments(query, topK, null);
}
- /**
- * 搜索相似文档(支持类别过滤)
- *
- * @param query 查询文本
- * @param topK 返回最相似的K个结果
- * @param category 类别过滤(可选,null 表示不过滤)
- * @return 搜索结果列表
- */
public List searchSimilarDocuments(String query, int topK, String category) {
+ String mode = vectorStoreMode == null ? "auto" : vectorStoreMode.trim().toLowerCase();
+ return switch (mode) {
+ case "sdk" -> searchSimilarDocumentsWithSdk(query, topK, category);
+ case "spring-ai" -> searchSimilarDocumentsWithVectorStore(query, topK, category);
+ case "auto" -> searchWithAutoFallback(query, topK, category);
+ default -> {
+ logger.warn("Unknown retrieval.vector-store.mode={}, using auto mode", vectorStoreMode);
+ yield searchWithAutoFallback(query, topK, category);
+ }
+ };
+ }
+
+ private List searchWithAutoFallback(String query, int topK, String category) {
try {
- logger.info("开始搜索相似文档, 查询: {}, topK: {}, 类别: {}", query, topK, category);
+ return searchSimilarDocumentsWithVectorStore(query, topK, category);
+ } catch (Exception e) {
+ logger.warn("Spring AI VectorStore retrieval failed, falling back to Milvus SDK: {}", e.getMessage());
+ return searchSimilarDocumentsWithSdk(query, topK, category);
+ }
+ }
+
+ List searchSimilarDocumentsWithVectorStore(String query, int topK, String category) {
+ VectorStore vectorStore = vectorStoreProvider != null ? vectorStoreProvider.getIfAvailable() : null;
+ if (vectorStore == null) {
+ throw new IllegalStateException("Spring AI VectorStore bean is unavailable");
+ }
+
+ logger.info("Starting Spring AI VectorStore search: query={}, topK={}, category={}", query, topK, category);
+ SearchRequest.Builder builder = SearchRequest.builder()
+ .query(query)
+ .topK(topK)
+ .similarityThresholdAll();
+ if (category != null && !category.trim().isEmpty()) {
+ String filterExpression = "category == '" + escapeFilterValue(category.trim()) + "'";
+ builder.filterExpression(filterExpression);
+ logger.info("Spring AI VectorStore category filter: {}", filterExpression);
+ }
+
+ List documents = vectorStore.similaritySearch(builder.build());
+ List results = new ArrayList<>();
+ for (Document document : documents) {
+ SearchResult result = new SearchResult();
+ result.setId(document.getId());
+ result.setContent(document.getText());
+ result.setMetadata(toJson(document.getMetadata()));
+ result.setRawScore(document.getScore());
+ result.setScoreLabel("similarity");
+ result.setScore(toCompatibleL2Distance(document));
+ results.add(result);
+ }
+ logger.info("Spring AI VectorStore search complete, candidates={}", results.size());
+ return results;
+ }
+
+ List searchSimilarDocumentsWithSdk(String query, int topK, String category) {
+ try {
+ logger.info("Starting Milvus SDK search: query={}, topK={}, category={}", query, topK, category);
- // 1. 将查询文本向量化
List queryVector = embeddingService.generateQueryVector(query);
- logger.debug("查询向量生成成功, 维度: {}", queryVector.size());
+ logger.debug("Query vector generated, dimension={}", queryVector.size());
- // 2. 构建搜索参数
SearchParam.Builder searchParamBuilder = SearchParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withVectorFieldName("vector")
@@ -69,33 +129,27 @@ public class VectorSearchService {
.withOutFields(List.of("id", "content", "metadata"))
.withParams("{\"nprobe\":10}");
- // 添加类别过滤
if (category != null && !category.trim().isEmpty()) {
String expr = String.format("metadata[\"category\"] == \"%s\"", category);
searchParamBuilder.withExpr(expr);
- logger.info("添加类别过滤: {}", expr);
+ logger.info("Milvus SDK category filter: {}", expr);
}
- SearchParam searchParam = searchParamBuilder.build();
-
- // 3. 执行搜索
- R searchResponse = milvusClient.search(searchParam);
-
+ R searchResponse = milvusClient.search(searchParamBuilder.build());
if (searchResponse.getStatus() != 0) {
- throw new RuntimeException("向量搜索失败: " + searchResponse.getMessage());
+ throw new RuntimeException("Vector search failed: " + searchResponse.getMessage());
}
- // 4. 解析搜索结果
SearchResultsWrapper wrapper = new SearchResultsWrapper(searchResponse.getData().getResults());
List results = new ArrayList<>();
-
for (int i = 0; i < wrapper.getRowRecords(0).size(); i++) {
SearchResult result = new SearchResult();
result.setId((String) wrapper.getIDScore(0).get(i).get("id"));
result.setContent((String) wrapper.getFieldData("content", 0).get(i));
result.setScore(wrapper.getIDScore(0).get(i).getScore());
+ result.setRawScore((double) result.getScore());
+ result.setScoreLabel("l2_distance");
- // 解析 metadata
Object metadataObj = wrapper.getFieldData("metadata", 0).get(i);
if (metadataObj != null) {
result.setMetadata(metadataObj.toString());
@@ -104,25 +158,76 @@ public class VectorSearchService {
results.add(result);
}
- logger.info("搜索完成, 找到 {} 个相似文档", results.size());
+ logger.info("Milvus SDK search complete, candidates={}", results.size());
return results;
-
} catch (Exception e) {
- logger.error("搜索相似文档失败", e);
- throw new RuntimeException("搜索失败: " + e.getMessage(), e);
+ logger.error("Milvus SDK vector search failed", e);
+ throw new RuntimeException("Vector search failed: " + e.getMessage(), e);
}
}
- /**
- * 搜索结果类
- */
+ private float toCompatibleL2Distance(Document document) {
+ Double distance = extractDistance(document.getMetadata());
+ if (distance != null) {
+ return distance.floatValue();
+ }
+ return toCompatibleL2Distance(document.getScore());
+ }
+
+ private float toCompatibleL2Distance(Double similarity) {
+ if (similarity == null) {
+ return (float) maxL2Distance;
+ }
+ double bounded = Math.max(0.0, Math.min(1.0, similarity));
+ return (float) ((1.0 - bounded) * maxL2Distance);
+ }
+
+ private Double extractDistance(Map metadata) {
+ if (metadata == null) {
+ return null;
+ }
+ Object value = metadata.get("distance");
+ if (value instanceof Number number) {
+ return number.doubleValue();
+ }
+ if (value instanceof String text) {
+ try {
+ return Double.parseDouble(text);
+ } catch (NumberFormatException ignored) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private String toJson(Map metadata) {
+ if (metadata == null || metadata.isEmpty()) {
+ return null;
+ }
+ try {
+ return objectMapper.writeValueAsString(metadata);
+ } catch (JsonProcessingException e) {
+ return metadata.toString();
+ }
+ }
+
+ private String escapeFilterValue(String value) {
+ return value.replace("'", "\\'");
+ }
+
@Setter
@Getter
public static class SearchResult {
private String id;
private String content;
+ /**
+ * Compatibility score used by existing lookup relevance normalization.
+ * SDK mode keeps L2 distance; VectorStore mode prefers the Milvus
+ * distance metadata and falls back to similarity mapping.
+ */
private float score;
+ private Double rawScore;
+ private String scoreLabel;
private String metadata;
-
}
}
diff --git a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java
index 1bf36f7..d864be3 100644
--- a/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java
+++ b/src/main/java/com/superbiz/agent/tool/LookupKnowledgeTool.java
@@ -13,7 +13,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
+import java.util.Map;
import java.util.stream.Collectors;
/**
@@ -34,13 +38,13 @@ public class LookupKnowledgeTool {
private static final String HINT_REFERENCE = "当前结果为相关参考,如需更精准信息请明确缺少的具体维度";
@Value("${retrieval.normalization.max-l2-distance:2.0}")
- private double maxL2Distance;
+ private double maxL2Distance = 2.0;
@Value("${retrieval.normalization.highly-relevant-threshold:0.75}")
- private double highlyRelevantThreshold;
+ private double highlyRelevantThreshold = 0.75;
@Value("${retrieval.normalization.reference-threshold:0.5}")
- private double referenceThreshold;
+ private double referenceThreshold = 0.5;
@Autowired
private KnowledgeIndexService knowledgeIndexService;
@@ -82,30 +86,28 @@ public class LookupKnowledgeTool {
log.info(">>> RequestId: {}", requestId);
log.info("----------------------------------------");
- // Step 1: L0 精确匹配
+ // Step 1: L0 hint 分析
long l0Start = System.currentTimeMillis();
- List l0Matches = knowledgeIndexService.exactMatch(query);
+ KnowledgeIndexService.L0Hint l0Hint = knowledgeIndexService.analyzeQuery(query);
+ List l0Matches = l0Hint.matches();
long l0Time = System.currentTimeMillis() - l0Start;
- log.info("[L0 精确匹配] 完成: matches={}, time={}ms", l0Matches.size(), l0Time);
+ log.info("[L0 Hint] 完成: matches={}, domains={}, keywords={}, time={}ms",
+ l0Matches.size(), l0Hint.domains(), l0Hint.matchedKeywords(), l0Time);
if (!l0Matches.isEmpty()) {
- log.info("[L0 精确匹配] 找到文档:");
+ log.info("[L0 Hint] 找到文档:");
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
KnowledgeEntry entry = l0Matches.get(i);
log.info(" - [{}] 标题: {}, 路径: {}, 域: {}", i+1, entry.getTitle(), entry.getFilePath(), entry.getCategory());
}
}
- // Step 2: 判断是否高置信度(唯一匹配)
- boolean highConfidence = (l0Matches.size() == 1);
- log.info("[置信度判断] highConfidence={}, reason={}",
- highConfidence, highConfidence ? "唯一匹配" : "多个或零个匹配");
-
- // Step 3: L1 条件调用
- List l1Results = null;
- if (!highConfidence) {
- log.info("[L1 语义检索] L0非唯一匹配,触发L1语义检索...");
+ // Step 2: L1 默认调用;L0 只提供可解释 hint 和可选 category filter
+ List l1Results = List.of();
+ String l0CategoryFilter = l0Hint.singleDomainOrNull();
+ try {
+ log.info("[L1 语义检索] 触发L1语义检索, categoryFilter={}", l0CategoryFilter);
long l1Start = System.currentTimeMillis();
- l1Results = vectorSearchService.searchSimilarDocuments(query, 3, null);
+ l1Results = vectorSearchService.searchSimilarDocuments(query, 3, l0CategoryFilter);
long l1Time = System.currentTimeMillis() - l1Start;
log.info("[L1 语义检索] 完成: matches={}, time={}ms",
l1Results != null ? l1Results.size() : 0, l1Time);
@@ -116,25 +118,27 @@ public class LookupKnowledgeTool {
log.info(" - [{}] 文档ID: {}, L2距离: {}", i+1, result.getId(), String.format("%.4f", result.getScore()));
}
}
- } else {
- log.info("[L1 语义检索] L0唯一匹配,跳过L1检索");
+ } catch (Exception e) {
+ log.warn("[L1 语义检索] 调用失败,保留L0 fallback: {}", e.getMessage());
+ l1Results = List.of();
}
- // Step 4: 归一化质量等级判定
+ // Step 3: 归一化质量等级判定
float l1TopScore = (l1Results != null && !l1Results.isEmpty()) ? l1Results.get(0).getScore() : Float.MAX_VALUE;
RelevanceAssessment assessment = computeRelevance(l0Matches.size(), l1TopScore);
+ boolean highConfidence = isHighConfidence(l0Matches.size(), l1TopScore);
log.info("[归一化] relevanceLevel={}, completenessHint={}", assessment.level, assessment.hint);
if (l1TopScore != Float.MAX_VALUE) {
double similarity = normalizeL2(l1TopScore);
log.info("[归一化] L2距离={}, similarity={}", String.format("%.4f", l1TopScore), String.format("%.4f", similarity));
}
- // Step 5: 组装结果
+ // Step 4: 组装结果
LookupResult result = buildResult(l0Matches, l1Results, highConfidence);
result.setRelevanceLevel(assessment.level);
result.setCompletenessHint(assessment.hint);
- // Step 6: session 级去重过滤 + 域级行动记忆
+ // Step 5: session 级去重过滤 + 域级行动记忆
String sessionId = SessionContextHolder.getSessionId();
String domain = extractDomain(l0Matches, l1Results);
@@ -143,7 +147,7 @@ public class LookupKnowledgeTool {
if (docKey != null && retrievedDocTracker.isAlreadyRetrieved(sessionId, docKey)) {
log.info("[去重] 文档已在本会话中检索过,跳过: {}", docKey);
List retrievedDomains = retrievedDocTracker.getRetrievedDomains(sessionId);
- saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
+ saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, "doc_retrieved");
return LookupResult.builder()
.found(false)
.message("文档已在本会话中检索过,无需重复召回: " + docKey)
@@ -196,7 +200,7 @@ public class LookupKnowledgeTool {
log.info("========================================");
// 记录 tool_invocation
- saveToolInvocation(query, l0Matches, l1Results, highConfidence, startTime, result, domain, null);
+ saveToolInvocation(query, l0Hint, l1Results, highConfidence, startTime, result, domain, null);
return result;
}
@@ -224,11 +228,16 @@ public class LookupKnowledgeTool {
RelevanceAssessment computeRelevance(int l0MatchCount, float l1TopScore) {
double l1Similarity = (l1TopScore != Float.MAX_VALUE) ? normalizeL2(l1TopScore) : 0.0;
- // L0 唯一匹配 → PRECISE
- if (l0MatchCount == 1) {
+ // L0 唯一匹配 + L1 高分 → PRECISE
+ if (l0MatchCount == 1 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_PRECISE, HINT_PRECISE);
}
+ // L0 唯一匹配但缺少 L1 支持 → REFERENCE
+ if (l0MatchCount == 1) {
+ return new RelevanceAssessment(LEVEL_REFERENCE, HINT_REFERENCE);
+ }
+
// L0 命中 + L1 高分 → HIGHLY_RELEVANT
if (l0MatchCount > 1 && l1Similarity >= highlyRelevantThreshold) {
return new RelevanceAssessment(LEVEL_HIGHLY_RELEVANT, HINT_HIGHLY_RELEVANT);
@@ -258,6 +267,16 @@ public class LookupKnowledgeTool {
return new RelevanceAssessment(null, null);
}
+ boolean isHighConfidence(int l0MatchCount, float l1TopScore) {
+ if (l0MatchCount != 1) {
+ return false;
+ }
+ if (l1TopScore == Float.MAX_VALUE) {
+ return true;
+ }
+ return normalizeL2(l1TopScore) >= highlyRelevantThreshold;
+ }
+
/**
* 归一化评估结果
*/
@@ -301,7 +320,7 @@ public class LookupKnowledgeTool {
/**
* 保存工具调用明细到 tool_invocation 表
*/
- private void saveToolInvocation(String query, List l0Matches,
+ private void saveToolInvocation(String query, KnowledgeIndexService.L0Hint l0Hint,
List l1Results,
boolean highConfidence, long startTime,
LookupResult result, String domain, String dedupReason) {
@@ -309,131 +328,30 @@ public class LookupKnowledgeTool {
String sessionId = SessionContextHolder.getSessionId();
if (sessionId == null) return;
- boolean hasL0 = l0Matches != null && !l0Matches.isEmpty();
- boolean hasL1 = l1Results != null && !l1Results.isEmpty();
long duration = System.currentTimeMillis() - startTime;
+ double l1TopSimilarity = (l1Results != null && !l1Results.isEmpty())
+ ? normalizeL2(l1Results.get(0).getScore())
+ : -1;
- String layer;
- String outputPreview = null;
- int outputLength = 0;
- int l0Count = 0;
- int l1Count = 0;
- boolean truncated = false;
-
- if (hasL0 && !highConfidence) {
- layer = "L0+L1";
- l0Count = l0Matches.size();
- l1Count = l1Results.size();
- } else if (hasL0) {
- layer = "L0";
- l0Count = l0Matches.size();
- } else if (hasL1) {
- layer = "L1";
- l1Count = l1Results.size();
- } else {
- layer = null;
- }
-
- // output_preview
- if (result != null && result.getPrimary() != null && result.getPrimary().getContent() != null) {
- String content = result.getPrimary().getContent();
- outputLength = content.length();
- if (content.length() > 500) {
- outputPreview = content.substring(0, 500) + "...";
- truncated = true;
- } else {
- outputPreview = content;
- }
- } else if (l1Results != null && !l1Results.isEmpty() && l1Results.get(0).getContent() != null) {
- String content = l1Results.get(0).getContent();
- outputLength = content.length();
- if (content.length() > 500) {
- outputPreview = content.substring(0, 500) + "...";
- truncated = true;
- } else {
- outputPreview = content;
- }
- }
-
- // L1 top score + similarity
- float l1TopScore = (hasL1) ? l1Results.get(0).getScore() : -1;
- double l1TopSimilarity = (hasL1) ? normalizeL2(l1TopScore) : -1;
-
- // 构建检索明细 JSON(扩展版)
- StringBuilder details = new StringBuilder("{");
- if (hasL0) {
- details.append("\"l0_match_count\":").append(l0Count).append(",");
- details.append("\"l0_titles\":[");
- for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
- if (i > 0) details.append(",");
- details.append("\"").append(escapeJson(l0Matches.get(i).getTitle())).append("\"");
- }
- details.append("],");
- }
- if (hasL1) {
- details.append("\"l1_top_score\":").append(String.format("%.4f", l1TopScore)).append(",");
- details.append("\"l1_top_similarity\":").append(String.format("%.4f", l1TopSimilarity)).append(",");
- details.append("\"l1_match_count\":").append(l1Count).append(",");
- details.append("\"l1_scores\":[");
- for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
- if (i > 0) details.append(",");
- details.append(String.format("%.4f", l1Results.get(i).getScore()));
- }
- details.append("],");
- }
- // 归一化信息
- if (result != null && result.getRelevanceLevel() != null) {
- details.append("\"relevance_level\":\"").append(result.getRelevanceLevel()).append("\",");
- details.append("\"completeness_hint\":\"").append(escapeJson(result.getCompletenessHint())).append("\",");
- }
- // 域信息
- if (domain != null) {
- details.append("\"retrieved_domains\":[\"").append(escapeJson(domain)).append("\"],");
- }
- // 去重原因
- if (dedupReason != null) {
- details.append("\"dedup_reason\":\"").append(dedupReason).append("\",");
- }
- // 移除末尾逗号
- if (details.charAt(details.length() - 1) == ',') {
- details.setLength(details.length() - 1);
- }
- details.append("}");
-
- ToolInvocation inv = ToolInvocation.builder()
- .sessionId(sessionId)
- .toolName("lookup_knowledge")
- .inputParams("{\"query\":\"" + escapeJson(query) + "\"}")
- .outputPreview(outputPreview)
- .outputLength(outputLength)
- .retrievalLayer(layer)
- .l0MatchCount(hasL0 ? l0Count : null)
- .l1MatchCount(hasL1 ? l1Count : null)
- .isTruncated(truncated)
- .retrievalDetails(details.toString())
- .relevanceLevel(result != null ? result.getRelevanceLevel() : null)
- .dedupReason(dedupReason)
- .durationMs((int) duration)
- .success(true)
- .build();
-
- toolInvocationRecorder.save(inv);
+ ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from(
+ query,
+ l0Hint,
+ l1Results,
+ highConfidence,
+ result,
+ domain,
+ dedupReason,
+ (int) duration,
+ l1TopSimilarity
+ );
+ toolInvocationRecorder.recordLookupKnowledge(record);
log.debug("tool_invocation 已保存: sessionId={}, layer={}, relevanceLevel={}, duration={}ms",
- sessionId, layer, result != null ? result.getRelevanceLevel() : null, duration);
+ sessionId, record.retrievalLayer(), record.relevanceLevel(), duration);
} catch (Exception e) {
log.error("保存 tool_invocation 失败", e);
}
}
- private String escapeJson(String s) {
- if (s == null) return "";
- return s.replace("\\", "\\\\")
- .replace("\"", "\\\"")
- .replace("\n", "\\n")
- .replace("\r", "\\r")
- .replace("\t", "\\t");
- }
-
// ==================== 结果组装 ====================
private LookupResult buildResult(
@@ -482,12 +400,154 @@ public class LookupKnowledgeTool {
}
builder.supplement(supplement);
+ EvidencePostprocessResult evidence = buildEvidenceBlocks(l0Matches, l1Results);
+ builder.evidenceBlocks(evidence.blocks());
+ builder.evidenceCandidateCount(evidence.candidateCount());
+ builder.evidenceBlockCount(evidence.blocks().size());
+
boolean found = (primary != null) || (supplement != null);
builder.found(found);
return builder.build();
}
+ private EvidencePostprocessResult buildEvidenceBlocks(
+ List l0Matches,
+ List l1Results) {
+ Map deduped = new LinkedHashMap<>();
+ int candidateCount = 0;
+
+ if (l0Matches != null) {
+ for (int i = 0; i < l0Matches.size(); i++) {
+ KnowledgeEntry entry = l0Matches.get(i);
+ candidateCount++;
+ EvidenceBlock block = EvidenceBlock.builder()
+ .source(entry.getFilePath())
+ .title(entry.getTitle())
+ .breadcrumb(null)
+ .retrievalLayer("L0")
+ .content(buildMetadataOnlySummary(entry))
+ .score(null)
+ .hitReasons(buildL0HitReasons(entry, i + 1))
+ .build();
+ mergeEvidence(deduped, sourceKey(block, "l0-" + i), block);
+ }
+ }
+
+ if (l1Results != null) {
+ for (int i = 0; i < l1Results.size(); i++) {
+ VectorSearchService.SearchResult result = l1Results.get(i);
+ candidateCount++;
+ Map metadata = parseMetadata(result.getMetadata());
+ String source = firstNonBlank(
+ metadata.get("_source"),
+ metadata.get("docId"),
+ result.getMetadata(),
+ result.getId()
+ );
+ EvidenceBlock block = EvidenceBlock.builder()
+ .source(source)
+ .title(metadata.get("title"))
+ .breadcrumb(metadata.get("breadcrumb"))
+ .retrievalLayer("L1")
+ .content(truncate(result.getContent(), 800))
+ .score((double) result.getScore())
+ .hitReasons(List.of("semantic_rank:" + (i + 1)))
+ .build();
+ mergeEvidence(deduped, sourceKey(block, "l1-" + i), block);
+ }
+ }
+
+ return new EvidencePostprocessResult(candidateCount, new ArrayList<>(deduped.values()));
+ }
+
+ private void mergeEvidence(Map deduped, String key, EvidenceBlock incoming) {
+ EvidenceBlock existing = deduped.get(key);
+ if (existing == null) {
+ deduped.put(key, incoming);
+ return;
+ }
+
+ List mergedReasons = new ArrayList<>();
+ if (existing.getHitReasons() != null) {
+ mergedReasons.addAll(existing.getHitReasons());
+ }
+ if (incoming.getHitReasons() != null) {
+ for (String reason : incoming.getHitReasons()) {
+ if (!mergedReasons.contains(reason)) {
+ mergedReasons.add(reason);
+ }
+ }
+ }
+
+ String mergedLayer = existing.getRetrievalLayer();
+ if (incoming.getRetrievalLayer() != null && !incoming.getRetrievalLayer().equals(mergedLayer)) {
+ mergedLayer = "L0+L1";
+ }
+
+ existing.setRetrievalLayer(mergedLayer);
+ existing.setHitReasons(mergedReasons);
+ if (existing.getScore() == null && incoming.getScore() != null) {
+ existing.setScore(incoming.getScore());
+ }
+ if ((existing.getBreadcrumb() == null || existing.getBreadcrumb().isBlank())
+ && incoming.getBreadcrumb() != null) {
+ existing.setBreadcrumb(incoming.getBreadcrumb());
+ }
+ }
+
+ private List buildL0HitReasons(KnowledgeEntry entry, int rank) {
+ List reasons = new ArrayList<>();
+ reasons.add("l0_rank:" + rank);
+ if (entry.getKeywords() != null && !entry.getKeywords().isEmpty()) {
+ reasons.add("l0_keywords:" + String.join(",", entry.getKeywords()));
+ }
+ if (entry.getCategory() != null && !entry.getCategory().isBlank()) {
+ reasons.add("domain:" + entry.getCategory());
+ }
+ return reasons;
+ }
+
+ private String sourceKey(EvidenceBlock block, String fallback) {
+ return firstNonBlank(block.getSource(), block.getTitle(), block.getBreadcrumb(), fallback);
+ }
+
+ private Map parseMetadata(String metadata) {
+ if (metadata == null || metadata.isBlank()) {
+ return Map.of();
+ }
+ try {
+ Map, ?> raw = objectMapper.readValue(metadata, Map.class);
+ Map result = new LinkedHashMap<>();
+ for (Map.Entry, ?> entry : raw.entrySet()) {
+ if (entry.getKey() != null && entry.getValue() != null) {
+ result.put(String.valueOf(entry.getKey()), String.valueOf(entry.getValue()));
+ }
+ }
+ return result;
+ } catch (Exception e) {
+ return Map.of();
+ }
+ }
+
+ private String firstNonBlank(String... values) {
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private String truncate(String text, int maxLength) {
+ if (text == null || text.length() <= maxLength) {
+ return text;
+ }
+ return text.substring(0, maxLength) + "...";
+ }
+
+ private record EvidencePostprocessResult(int candidateCount, List blocks) {}
+
private int countMdHeadings(String content) {
if (content == null) return 0;
return (int) content.lines()
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 1df7156..5ae16ca 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -47,6 +47,14 @@ spring:
username: root
password: '!Fucker123..'
driver-class-name: com.mysql.cj.jdbc.Driver
+ hikari:
+ maximum-pool-size: 5
+ minimum-idle: 1
+ connection-timeout: 10000
+ validation-timeout: 5000
+ idle-timeout: 60000
+ max-lifetime: 120000
+ keepalive-time: 30000
# =====================================================
# JPA 配置
@@ -85,6 +93,30 @@ spring:
min-idle: 0
ai:
+ vectorstore:
+ type: milvus
+ milvus:
+ initialize-schema: false
+ database-name: ${milvus.database}
+ collection-name: biz
+ embedding-dimension: ${milvus.vector-dim}
+ index-type: IVF_FLAT
+ metric-type: L2
+ index-parameters: '{"nlist":128}'
+ id-field-name: id
+ auto-id: false
+ content-field-name: content
+ metadata-field-name: metadata
+ embedding-field-name: vector
+ client:
+ host: ${milvus.host}
+ port: ${milvus.port}
+ token: ${milvus.token}
+ username: ${milvus.username}
+ password: ${milvus.password}
+ secure: ${milvus.secure}
+ connect-timeout-ms: ${milvus.timeout}
+
# --- Chat: DeepSeek (原生) ---
deepseek:
api-key: sk-1f44696abe644bd684f09cc43f12c557
@@ -118,9 +150,15 @@ document:
# RAG 配置
rag:
top-k: 3 # 检索返回的最相似文档数量
+ sidecar:
+ spring-ai:
+ enabled: false
+ content-preview-limit: 300
# 检索归一化配置
retrieval:
+ vector-store:
+ mode: auto # auto | spring-ai | sdk
normalization:
max-l2-distance: 2.0 # L2 距离上界(BGE-M3 单位向量 = 2.0)
highly-relevant-threshold: 0.75 # similarity >= 0.75 → HIGHLY_RELEVANT
diff --git a/src/main/resources/skills/diagnose-aiops-alert/SKILL.md b/src/main/resources/skills/diagnose-aiops-alert/SKILL.md
new file mode 100644
index 0000000..678477c
--- /dev/null
+++ b/src/main/resources/skills/diagnose-aiops-alert/SKILL.md
@@ -0,0 +1,38 @@
+---
+name: diagnose-aiops-alert
+description: Diagnose AIOps alert payloads, active Prometheus alerts, alert scope control, HighCPUUsage, HighMemoryUsage, SlowResponse, ServiceUnavailable, and alert-driven incident reports. Use in AIOps flows or when the user asks to diagnose current alerts.
+---
+
+# AIOps Alert Diagnosis
+
+## Workflow
+
+1. Determine scope mode.
+ - Payload present: treat the supplied alert as the primary diagnosis target.
+ - No payload: call `queryPrometheusAlerts` first and choose P0/P1 or the longest-running firing alert.
+2. For payload mode, preserve alert name, service, severity, description, and time range in the `lookup_knowledge` query.
+3. Confirm active alert state with `queryPrometheusAlerts` when useful, but do not diagnose unrelated alerts as the main target.
+4. Query metrics/logs that match the alert type and service.
+5. Produce a report that distinguishes confirmed evidence, related risks, and missing evidence.
+
+## Required Evidence
+
+- Alert state from payload or `queryPrometheusAlerts`.
+- `lookup_knowledge` when playbook or runbook guidance is needed.
+- Logs and metrics aligned to the alert type.
+
+## Stop Conditions
+
+- If payload mode returns unrelated active alerts, mention them only as related risk.
+- If three calls in the same direction fail or return no data, stop that direction and report the failure.
+- Do not invent metric values, log lines, or remediation execution results.
+
+## Report Rules
+
+- Use the existing alert analysis report structure.
+- Keep the supplied alert as the main diagnosis target in payload mode.
+- Include confidence and evidence gaps.
+
+## Eval Anchor
+
+RAG cases: `aiops-payment-latency-alert`, `aiops-prometheus-alert-scope`.
diff --git a/src/main/resources/skills/diagnose-jvm-memory-risk/SKILL.md b/src/main/resources/skills/diagnose-jvm-memory-risk/SKILL.md
new file mode 100644
index 0000000..04f8474
--- /dev/null
+++ b/src/main/resources/skills/diagnose-jvm-memory-risk/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: diagnose-jvm-memory-risk
+description: Diagnose JVM memory risk, high heap usage, OOM risk, OutOfMemoryError, frequent Full GC, memory leak, pod OOMKilled, or order-service memory alerts. Use when memory, JVM, heap, GC, OOM, or OOMKilled appears.
+---
+
+# JVM Memory Risk Diagnosis
+
+## Workflow
+
+1. Extract affected service, memory threshold, heap size, GC symptoms, pod/container events, and time window.
+2. Call `query_metrics` or alert tools for heap usage, memory usage, GC count/time, and active memory alerts.
+3. Call `query_logs` for Full GC warnings, OutOfMemoryError, OOMKilled, restart events, or allocation-heavy stack traces.
+4. Call `lookup_knowledge` when JVM memory troubleshooting or remediation guidance is needed.
+5. Decide whether the supported risk is high memory pressure, confirmed OOM, suspected leak, or insufficient evidence.
+
+## Required Evidence
+
+- `query_metrics` for resource pressure claims.
+- `query_logs` for OOM, GC, or restart evidence.
+
+## Stop Conditions
+
+- High memory usage alone is not proof of memory leak.
+- OOM risk is stronger when high memory metrics align with Full GC, OOMKilled, or OutOfMemoryError logs.
+- If evidence is incomplete, return LOW_CONFID wording and list the missing metrics/logs.
+
+## Report Rules
+
+- Include immediate mitigation, heap/GC investigation, leak investigation, and monitoring recommendations.
+- Do not say the issue can be ignored while memory remains above threshold.
+
+## Eval Anchor
+
+Fixed diagnosis case: `jvm-memory-risk`.
diff --git a/src/main/resources/skills/diagnose-mysql-connection-pool/SKILL.md b/src/main/resources/skills/diagnose-mysql-connection-pool/SKILL.md
new file mode 100644
index 0000000..ce32054
--- /dev/null
+++ b/src/main/resources/skills/diagnose-mysql-connection-pool/SKILL.md
@@ -0,0 +1,35 @@
+---
+name: diagnose-mysql-connection-pool
+description: Diagnose MySQL, HikariCP, database connection pool exhaustion, connection acquisition timeout, slow SQL, connection leak, or database saturation issues. Use when the user mentions MySQL pool, HikariCP, connection pool, database timeout, order-service timeout, or connection exhaustion.
+---
+
+# MySQL Connection Pool Diagnosis
+
+## Workflow
+
+1. Extract service, database, timeout symptom, and time window.
+2. Call `lookup_knowledge` with MySQL, HikariCP, connection pool, and the affected service.
+3. Call `query_logs` for connection acquisition timeout, active/max pool counts, waiting threads, leak warnings, slow query, or lock waits.
+4. Call `query_metrics` when metrics are available for active connections, idle connections, wait time, DB latency, and error rate.
+5. Decide whether the evidence supports pool exhaustion, slow SQL causing saturation, connection leak, or insufficient evidence.
+
+## Required Evidence
+
+- `lookup_knowledge` for pool configuration and diagnosis guidance.
+- `query_logs` for concrete pool or SQL symptoms.
+- `query_metrics` when making saturation or capacity claims.
+
+## Stop Conditions
+
+- Confirmed pool exhaustion requires log or metric evidence such as active equals max, waiting threads, acquisition timeout, or leak warnings.
+- If only request timeout is present without pool evidence, state that the pool hypothesis is unconfirmed.
+- If logs show slow SQL but not pool saturation, report slow SQL as the stronger supported cause.
+
+## Report Rules
+
+- Include current evidence, likely root cause, missing evidence, short-term mitigation, and long-term fix.
+- Avoid saying "fully confirmed" unless at least two evidence sources align.
+
+## Eval Anchor
+
+Fixed diagnosis case: `mysql-pool-exhausted`.
diff --git a/src/main/resources/skills/diagnose-payment-timeout/SKILL.md b/src/main/resources/skills/diagnose-payment-timeout/SKILL.md
new file mode 100644
index 0000000..a0e549c
--- /dev/null
+++ b/src/main/resources/skills/diagnose-payment-timeout/SKILL.md
@@ -0,0 +1,37 @@
+---
+name: diagnose-payment-timeout
+description: Diagnose payment API, payment gateway, ERR_TIMEOUT, gateway timeout, payment-service latency, or payment request timeout issues. Use when the user mentions payment timeout, ERR_TIMEOUT, ERR_GATEWAY_TIMEOUT, slow payment, or payment-service latency.
+---
+
+# Payment Timeout Diagnosis
+
+## Workflow
+
+1. Identify the affected payment service, error code, endpoint, and time window from the user request.
+2. Call `read_skill` only once for this playbook, then follow the evidence order below.
+3. Call `lookup_knowledge` with a narrow query containing payment, timeout, the error code if present, and the affected service.
+4. Call `query_logs` for payment-service timeout, downstream dependency timeout, gateway timeout, or request duration above threshold.
+5. Call `query_metrics` or alert tools for latency, error rate, saturation, and active alerts when metrics are available.
+6. Compare knowledge guidance with logs and metrics before stating a root cause.
+
+## Required Evidence
+
+- `lookup_knowledge` for error-code or payment timeout guidance.
+- `query_logs` for concrete timeout or dependency evidence.
+- `query_metrics` when the question asks for impact, latency, or current alert state.
+
+## Stop Conditions
+
+- If only knowledge is available and logs/metrics are missing, return LOW_CONFID language.
+- If tools fail or return no evidence, state which evidence is missing and do not claim a confirmed root cause.
+- Do not repeatedly call `lookup_knowledge` with synonym-only queries after a relevant result.
+
+## Report Rules
+
+- Separate immediate mitigation from long-term remediation.
+- Cite the evidence source type for each key conclusion.
+- Do not claim payment provider failure unless logs or metrics support an upstream dependency issue.
+
+## Eval Anchor
+
+Fixed diagnosis case: `payment-timeout`.
diff --git a/src/main/resources/skills/diagnose-redis-timeout/SKILL.md b/src/main/resources/skills/diagnose-redis-timeout/SKILL.md
new file mode 100644
index 0000000..3afe6f5
--- /dev/null
+++ b/src/main/resources/skills/diagnose-redis-timeout/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: diagnose-redis-timeout
+description: Diagnose Redis timeout, Redis connection timeout, cache dependency timeout, Redis cluster unavailable, hot key, network latency, or payment-service Redis dependency failures. Use when Redis or cache timeout appears in the user request, logs, or alert payload.
+---
+
+# Redis Timeout Diagnosis
+
+## Workflow
+
+1. Extract affected service, Redis operation, host/cluster, timeout value, and time window.
+2. Call `lookup_knowledge` for Redis timeout or cache troubleshooting guidance when knowledge evidence is needed.
+3. Call `query_logs` for Redis connection timeout, retry count, host, command latency, hot key, or dependency errors.
+4. Call `query_metrics` when available for Redis latency, connection count, CPU, memory, error rate, or network saturation.
+5. Distinguish client timeout, Redis saturation, network issue, and missing evidence.
+
+## Required Evidence
+
+- `query_logs` is mandatory for a concrete Redis timeout claim.
+- `lookup_knowledge` is recommended for remediation and configuration guidance.
+- `query_metrics` is required before claiming Redis resource saturation.
+
+## Stop Conditions
+
+- If only one Redis timeout log exists and no metrics are available, return LOW_CONFID wording.
+- If Redis is only mentioned as a possible downstream dependency, do not make it the root cause without supporting logs.
+
+## Report Rules
+
+- State whether the supported issue is client-side timeout, Redis cluster issue, network issue, or unconfirmed.
+- Include retry/backoff, timeout tuning, connection pool, and monitoring recommendations only when relevant.
+
+## Eval Anchor
+
+Fixed diagnosis case: `redis-timeout`.
diff --git a/src/main/resources/skills/diagnose-slow-response/SKILL.md b/src/main/resources/skills/diagnose-slow-response/SKILL.md
new file mode 100644
index 0000000..8fbb12e
--- /dev/null
+++ b/src/main/resources/skills/diagnose-slow-response/SKILL.md
@@ -0,0 +1,33 @@
+---
+name: diagnose-slow-response
+description: Diagnose slow response, high P95/P99 latency, API latency regression, slow request, downstream latency, or user-service response time alerts. Use when the user mentions P99, P95, response time, slow endpoint, latency, or SlowResponse alerts.
+---
+
+# Slow Response Diagnosis
+
+## Workflow
+
+1. Extract service, endpoint, latency percentile, threshold, and time window.
+2. Call `query_metrics` or alert tools to confirm latency and impact.
+3. Call `query_logs` for slow request records, endpoint duration, downstream timing, cache misses, or database query timeout.
+4. Call `lookup_knowledge` when process guidance, service-specific runbook, or known failure mode evidence is needed.
+5. Classify the supported cause: database slow query, downstream dependency, cache miss, resource saturation, or insufficient evidence.
+
+## Required Evidence
+
+- `query_metrics` for latency or alert confirmation.
+- `query_logs` for endpoint-level or dependency-level evidence.
+
+## Stop Conditions
+
+- If metrics show latency but logs do not identify a cause, say impact is confirmed but root cause is not.
+- If logs identify slow SQL or dependency latency, use that as a candidate cause and mark confidence based on metric alignment.
+
+## Report Rules
+
+- Include impacted endpoints, observed latency, suspected bottleneck, evidence gaps, and next checks.
+- Do not say there is no risk when P95/P99 remains above threshold.
+
+## Eval Anchor
+
+Fixed diagnosis case: `slow-response`.
diff --git a/src/test/java/com/superbiz/agent/eval/DiagnosisEvalBaselineDiffTest.java b/src/test/java/com/superbiz/agent/eval/DiagnosisEvalBaselineDiffTest.java
new file mode 100644
index 0000000..d6c7b75
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/eval/DiagnosisEvalBaselineDiffTest.java
@@ -0,0 +1,140 @@
+package com.superbiz.agent.eval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DiagnosisEvalBaselineDiffTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final DiagnosisEvalBaselineDiffer differ = new DiagnosisEvalBaselineDiffer();
+
+ @Test
+ void compareReportsDetectsAggregateAndCaseRegressions() throws Exception {
+ DiagnosisEvalReport baseline = readBaselineReport();
+ DiagnosisEvalReport current = readBaselineReport();
+ degradeRedisCase(current);
+
+ DiagnosisEvalDiffReport diff = differ.compare(baseline, current);
+
+ assertTrue(diff.isHasRegression());
+ assertEquals(6, diff.getRegressionCount());
+ assertEquals(2, diff.getChangedCount());
+ assertTrue(hasItem(diff, "REGRESSION", "aggregate", null, "passRate"));
+ assertTrue(hasItem(diff, "REGRESSION", "aggregate", null, "averageToolCallCount"));
+ assertTrue(hasItem(diff, "REGRESSION", "case", "redis-timeout", "passed"));
+ assertTrue(hasItem(diff, "REGRESSION", "case", "redis-timeout", "verdict"));
+ assertTrue(hasItem(diff, "REGRESSION", "case", "redis-timeout", "matchedKeywordCount"));
+ assertTrue(hasItem(diff, "REGRESSION", "case", "redis-timeout", "evidenceCoverage.query_logs"));
+ assertTrue(hasItem(diff, "CHANGED", "aggregate", null, "verdictDistribution.LOW_CONFID"));
+ assertTrue(hasItem(diff, "CHANGED", "aggregate", null, "verdictDistribution.REJECT"));
+ }
+
+ @Test
+ void compareReportsDetectsMissingAndNewCases() throws Exception {
+ DiagnosisEvalReport baseline = readBaselineReport();
+ DiagnosisEvalReport current = readBaselineReport();
+ DiagnosisEvalResult removed = current.getResults().remove(0);
+ current.getResults().add(DiagnosisEvalResult.builder()
+ .caseId("new-case")
+ .title("New case")
+ .passed(true)
+ .failedChecks(List.of())
+ .verdict("PASS")
+ .matchedKeywordCount(1)
+ .requiredKeywordCount(1)
+ .evidenceCoverage(new LinkedHashMap<>())
+ .toolCallCount(1)
+ .durationMs(1000)
+ .build());
+
+ DiagnosisEvalDiffReport diff = differ.compare(baseline, current);
+
+ assertTrue(hasItem(diff, "REGRESSION", "case", removed.getCaseId(), "casePresence"));
+ assertTrue(hasItem(diff, "CHANGED", "case", "new-case", "casePresence"));
+ }
+
+ @Test
+ void compareSameReportHasNoDiff() throws Exception {
+ DiagnosisEvalReport baseline = readBaselineReport();
+
+ DiagnosisEvalDiffReport diff = differ.compare(baseline, readBaselineReport());
+
+ assertFalse(diff.isHasRegression());
+ assertEquals(0, diff.getRegressionCount());
+ assertTrue(diff.getItems().isEmpty());
+ }
+
+ @Test
+ void writerOutputsJsonAndMarkdown(@TempDir Path tempDir) throws Exception {
+ DiagnosisEvalReport baseline = readBaselineReport();
+ DiagnosisEvalReport current = readBaselineReport();
+ degradeRedisCase(current);
+ DiagnosisEvalDiffReport diff = differ.compare(baseline, current);
+ DiagnosisEvalDiffReportWriter writer = new DiagnosisEvalDiffReportWriter(objectMapper);
+
+ Path json = tempDir.resolve("baseline-diff.json");
+ Path markdown = tempDir.resolve("baseline-diff.md");
+ writer.writeJson(diff, json);
+ writer.writeMarkdown(diff, markdown);
+
+ assertTrue(Files.exists(json));
+ assertTrue(Files.readString(json).contains("\"hasRegression\" : true"));
+ assertTrue(Files.readString(markdown).contains("# Diagnosis Eval Baseline Diff"));
+ assertTrue(Files.readString(markdown).contains("redis-timeout"));
+ }
+
+ private DiagnosisEvalReport readBaselineReport() throws Exception {
+ return objectMapper.readValue(Path.of("mvp/eval/reports/baseline-report.json").toFile(),
+ DiagnosisEvalReport.class);
+ }
+
+ private void degradeRedisCase(DiagnosisEvalReport report) {
+ report.setPassedCases(4);
+ report.setPassRate(0.8);
+ report.setAverageToolCallCount(3.0);
+ report.setAverageDurationMs(45800.0);
+ report.setVerdictDistribution(new LinkedHashMap<>());
+ report.getVerdictDistribution().put("PASS", 2L);
+ report.getVerdictDistribution().put("LOW_CONFID", 2L);
+ report.getVerdictDistribution().put("REJECT", 1L);
+
+ DiagnosisEvalResult redis = result(report, "redis-timeout");
+ redis.setPassed(false);
+ redis.setFailedChecks(new ArrayList<>(List.of("missing required evidence tool: query_logs")));
+ redis.setVerdict("REJECT");
+ redis.setMatchedKeywordCount(1);
+ redis.getEvidenceCoverage().put("query_logs", false);
+ redis.setToolCallCount(1);
+ redis.setDurationMs(36000);
+ }
+
+ private DiagnosisEvalResult result(DiagnosisEvalReport report, String caseId) {
+ return report.getResults().stream()
+ .filter(item -> caseId.equals(item.getCaseId()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private boolean hasItem(DiagnosisEvalDiffReport diff,
+ String type,
+ String scope,
+ String caseId,
+ String metric) {
+ return diff.getItems().stream().anyMatch(item ->
+ type.equals(item.getType())
+ && scope.equals(item.getScope())
+ && java.util.Objects.equals(caseId, item.getCaseId())
+ && metric.equals(item.getMetric()));
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/eval/DiagnosisTraceEvaluatorTest.java b/src/test/java/com/superbiz/agent/eval/DiagnosisTraceEvaluatorTest.java
new file mode 100644
index 0000000..8a0c9a8
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/eval/DiagnosisTraceEvaluatorTest.java
@@ -0,0 +1,115 @@
+package com.superbiz.agent.eval;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.superbiz.agent.dto.DiagnosisTraceResponse;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DiagnosisTraceEvaluatorTest {
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final DiagnosisTraceEvaluator evaluator = new DiagnosisTraceEvaluator(objectMapper);
+
+ @Test
+ void evaluateFixtureReportsFullBaseline() {
+ List cases = readCases();
+
+ DiagnosisEvalReport report = evaluator.evaluate(cases, Path.of("mvp/eval/fixtures"));
+
+ assertEquals(5, report.getTotalCases());
+ assertEquals(5, report.getPassedCases());
+ assertEquals(1.0, report.getPassRate(), 0.001);
+ assertEquals(2L, report.getVerdictDistribution().get("PASS"));
+ assertEquals(3L, report.getVerdictDistribution().get("LOW_CONFID"));
+
+ DiagnosisEvalResult payment = result(report, "payment-timeout");
+ assertTrue(payment.isPassed());
+ assertTrue(payment.getEvidenceCoverage().get("lookup_knowledge"));
+ assertTrue(payment.getEvidenceCoverage().get("query_logs"));
+ assertTrue(payment.getEvidenceCoverage().get("query_metrics"));
+
+ DiagnosisEvalResult redis = result(report, "redis-timeout");
+ assertTrue(redis.isPassed());
+ assertTrue(redis.getEvidenceCoverage().get("query_logs"));
+ }
+
+ @Test
+ void everyFixedCaseReferencesExistingFixture() {
+ for (DiagnosisEvalCase evalCase : readCases()) {
+ Path fixture = Path.of("mvp/eval/fixtures").resolve(evalCase.getTraceFixture());
+ assertTrue(Files.exists(fixture), "missing fixture: " + fixture);
+ }
+ }
+
+ @Test
+ void evaluateRejectRequiresDegradedOutput() {
+ DiagnosisEvalCase evalCase = DiagnosisEvalCase.builder()
+ .id("reject-case")
+ .title("Reject case")
+ .expectedRootCauseKeywords(List.of())
+ .requiredEvidenceTools(List.of())
+ .allowedVerdicts(List.of("REJECT"))
+ .build();
+ DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder()
+ .session(DiagnosisTraceResponse.SessionTrace.builder()
+ .answer("EXECUTOR_FINAL_ANSWER")
+ .selfEvaluation(java.util.Map.of(
+ "verifier_evaluation", java.util.Map.of("verdict", "REJECT")))
+ .build())
+ .toolInvocations(List.of())
+ .build();
+
+ DiagnosisEvalResult result = evaluator.evaluate(evalCase, trace);
+
+ assertFalse(result.isPassed());
+ assertTrue(result.getFailedChecks().contains("reject output does not use degraded template"));
+ }
+
+ @Test
+ void reportWriterOutputsJsonAndMarkdown(@TempDir Path tempDir) throws Exception {
+ DiagnosisEvalReport report = evaluator.evaluate(readCases(), Path.of("mvp/eval/fixtures"));
+ DiagnosisEvalReportWriter writer = new DiagnosisEvalReportWriter(objectMapper);
+
+ Path json = tempDir.resolve("eval-report.json");
+ Path markdown = tempDir.resolve("eval-report.md");
+ writer.writeJson(report, json);
+ writer.writeMarkdown(report, markdown);
+
+ assertTrue(Files.exists(json));
+ assertTrue(Files.readString(markdown).contains("# Diagnosis Eval Report"));
+ assertTrue(Files.readString(markdown).contains("payment-timeout"));
+ assertEquals(
+ comparableReportText(Files.readString(Path.of("mvp/eval/reports/baseline-report.json"))),
+ comparableReportText(Files.readString(json)));
+ assertEquals(
+ comparableReportText(Files.readString(Path.of("mvp/eval/reports/baseline-report.md"))),
+ comparableReportText(Files.readString(markdown)));
+ }
+
+ private List readCases() {
+ try {
+ return evaluator.loadCases(Path.of("mvp/eval/cases/diagnosis-cases.json"));
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private DiagnosisEvalResult result(DiagnosisEvalReport report, String caseId) {
+ return report.getResults().stream()
+ .filter(item -> caseId.equals(item.getCaseId()))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private String comparableReportText(String value) {
+ return value.replace("\r\n", "\n").stripTrailing();
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/AiOpsRuleEvaluationServiceTest.java b/src/test/java/com/superbiz/agent/service/AiOpsRuleEvaluationServiceTest.java
new file mode 100644
index 0000000..70cc8a9
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/AiOpsRuleEvaluationServiceTest.java
@@ -0,0 +1,56 @@
+package com.superbiz.agent.service;
+
+import com.superbiz.agent.domain.entity.ToolInvocation;
+import com.superbiz.agent.dto.AIOpsRequest;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class AiOpsRuleEvaluationServiceTest {
+
+ private final AiOpsRuleEvaluationService service = new AiOpsRuleEvaluationService();
+
+ @Test
+ void evaluatePassesWhenReportFocusesPayloadAndHasEvidenceTools() {
+ AIOpsRequest request = new AIOpsRequest();
+ request.setAlertName("HighCPUUsage");
+ request.setService("payment-service");
+
+ ToolInvocation invocation = ToolInvocation.builder()
+ .toolName("lookup_knowledge")
+ .build();
+
+ Map evaluation = service.evaluate(
+ request,
+ "HighCPUUsage alert on payment-service was diagnosed using metrics and knowledge evidence.",
+ List.of(invocation)
+ );
+
+ assertEquals("PASS", evaluation.get("verdict"));
+ }
+
+ @Test
+ void evaluateWarnsWhenEvidenceToolsAreMissing() {
+ AIOpsRequest request = new AIOpsRequest();
+ request.setAlertName("HighCPUUsage");
+ request.setService("payment-service");
+
+ Map evaluation = service.evaluate(
+ request,
+ "HighCPUUsage alert on payment-service has a likely resource saturation issue.",
+ List.of()
+ );
+
+ assertEquals("WARN", evaluation.get("verdict"));
+ }
+
+ @Test
+ void evaluateFailsWhenReportIsMissing() {
+ Map evaluation = service.evaluate(null, "too short", List.of());
+
+ assertEquals("FAIL", evaluation.get("verdict"));
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/AiOpsServiceTest.java b/src/test/java/com/superbiz/agent/service/AiOpsServiceTest.java
new file mode 100644
index 0000000..c6c8baf
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/AiOpsServiceTest.java
@@ -0,0 +1,191 @@
+package com.superbiz.agent.service;
+
+import com.superbiz.agent.domain.entity.AgentStep;
+import com.superbiz.agent.domain.entity.DiagnosisSession;
+import com.superbiz.agent.dto.AIOpsRequest;
+import com.superbiz.agent.repository.AgentStepRepository;
+import com.superbiz.agent.repository.DiagnosisSessionRepository;
+import com.superbiz.agent.repository.ToolInvocationRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Optional;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class AiOpsServiceTest {
+
+ private final DiagnosisSessionRepository diagnosisSessionRepository = mock(DiagnosisSessionRepository.class);
+ private final AgentStepRepository agentStepRepository = mock(AgentStepRepository.class);
+ private final ToolInvocationRepository toolInvocationRepository = mock(ToolInvocationRepository.class);
+ private final AiOpsService service = new AiOpsService();
+
+ @BeforeEach
+ void setUp() {
+ ReflectionTestUtils.setField(service, "diagnosisSessionRepository", diagnosisSessionRepository);
+ ReflectionTestUtils.setField(service, "agentStepRepository", agentStepRepository);
+ ReflectionTestUtils.setField(service, "toolInvocationRepository", toolInvocationRepository);
+ ReflectionTestUtils.setField(service, "aiOpsRuleEvaluationService", new AiOpsRuleEvaluationService());
+ ReflectionTestUtils.setField(service, "selfEvaluationMergeService", new SelfEvaluationMergeService());
+ }
+
+ @Test
+ void resolveSessionIdUsesRequestValueWhenPresent() {
+ AIOpsRequest request = new AIOpsRequest();
+ request.setSessionId(" aiops-demo-session ");
+
+ assertEquals("aiops-demo-session", service.resolveSessionId(request));
+ }
+
+ @Test
+ void resolveSessionIdGeneratesWhenMissing() {
+ String sessionId = service.resolveSessionId(null);
+
+ assertNotNull(sessionId);
+ assertFalse(sessionId.isBlank());
+ }
+
+ @Test
+ void buildQuerySummaryUsesAlertFieldsAndUserRequestFallback() {
+ AIOpsRequest request = new AIOpsRequest();
+ request.setAlertName("payment-service-latency-high");
+ request.setService("payment-service");
+ request.setSeverity("P1");
+ request.setTimeRange("last_15m");
+ request.setDescription("P95 latency is high");
+ request.setUserRequest("check logs and metrics for payment timeout");
+
+ String summary = service.buildQuerySummary(request);
+
+ assertTrue(summary.contains("AI Ops alert analysis"));
+ assertTrue(summary.contains("alert: payment-service-latency-high"));
+ assertTrue(summary.contains("service: payment-service"));
+ assertTrue(summary.contains("severity: P1"));
+ assertTrue(summary.contains("timeRange: last_15m"));
+ assertTrue(summary.contains("description: P95 latency is high"));
+ assertTrue(summary.contains("request: "));
+ }
+
+ @Test
+ void hasAlertPayloadIgnoresUserRequestOnly() {
+ AIOpsRequest request = new AIOpsRequest();
+ request.setUserRequest("please discover active alerts");
+
+ assertFalse(service.hasAlertPayload(request));
+
+ request.setAlertName("HighCPUUsage");
+
+ assertTrue(service.hasAlertPayload(request));
+ }
+
+ @Test
+ void buildTaskPromptUsesPayloadTargetedModeWhenAlertFieldsExist() {
+ AIOpsRequest request = new AIOpsRequest();
+ request.setAlertName("HighCPUUsage");
+ request.setService("payment-service");
+ request.setSeverity("P1");
+ request.setTimeRange("last_15m");
+ request.setDescription("CPU usage is above 80%");
+
+ String prompt = service.buildTaskPrompt(request);
+
+ assertTrue(prompt.contains("AIOps scope mode: PAYLOAD_TARGETED"));
+ assertTrue(prompt.contains("primary and only main diagnosis target"));
+ assertTrue(prompt.contains("queryPrometheusAlerts only to verify"));
+ assertTrue(prompt.contains("do not create full root-cause or remediation sections"));
+ assertTrue(prompt.contains("Related Risk"));
+ assertTrue(prompt.contains("Recommended lookup_knowledge query: HighCPUUsage payment-service P1 CPU usage is above 80% last_15m"));
+ assertTrue(prompt.contains("preserves alertName and service"));
+ assertTrue(prompt.contains("alert: HighCPUUsage"));
+ assertTrue(prompt.contains("service: payment-service"));
+ assertFalse(prompt.contains("AIOps scope mode: AUTO_DISCOVERY"));
+ }
+
+ @Test
+ void buildKnowledgeRetrievalQueryUsesPayloadFieldsAndSkipsBlankValues() {
+ AIOpsRequest request = new AIOpsRequest();
+ request.setAlertName("HighLatency");
+ request.setService(" payment-service ");
+ request.setSeverity(" ");
+ request.setDescription("P95 latency above threshold");
+ request.setTimeRange("last_10m");
+ request.setUserRequest("check logs and metrics");
+
+ String query = service.buildKnowledgeRetrievalQuery(request);
+
+ assertEquals("HighLatency payment-service P95 latency above threshold last_10m check logs and metrics", query);
+ }
+
+ @Test
+ void buildTaskPromptUsesAutoDiscoveryModeWhenAlertPayloadIsMissing() {
+ String nullRequestPrompt = service.buildTaskPrompt(null);
+
+ assertTrue(nullRequestPrompt.contains("AIOps scope mode: AUTO_DISCOVERY"));
+ assertTrue(nullRequestPrompt.contains("First call queryPrometheusAlerts"));
+ assertTrue(nullRequestPrompt.contains("current active/firing alerts"));
+ assertFalse(nullRequestPrompt.contains("AIOps scope mode: PAYLOAD_TARGETED"));
+
+ AIOpsRequest userRequestOnly = new AIOpsRequest();
+ userRequestOnly.setUserRequest("check what is firing now");
+
+ String userRequestOnlyPrompt = service.buildTaskPrompt(userRequestOnly);
+
+ assertTrue(userRequestOnlyPrompt.contains("AIOps scope mode: AUTO_DISCOVERY"));
+ assertTrue(userRequestOnlyPrompt.contains("First call queryPrometheusAlerts"));
+ assertFalse(userRequestOnlyPrompt.contains("Recommended lookup_knowledge query"));
+ }
+
+ @Test
+ void persistFinalReportUpdatesDiagnosisSessionAnswer() {
+ DiagnosisSession session = DiagnosisSession.builder()
+ .sessionId("aiops-session-001")
+ .query("AI Ops alert analysis")
+ .status("SUCCESS")
+ .agentFlow("AI_OPS")
+ .build();
+ when(diagnosisSessionRepository.findBySessionId("aiops-session-001")).thenReturn(Optional.of(session));
+ when(toolInvocationRepository.findBySessionIdOrderByIdAsc("aiops-session-001")).thenReturn(List.of());
+
+ service.persistFinalReport("aiops-session-001", "# 闁告稑锕ㄩ鐔煎礆閸℃鈧粙骞庨妷銉﹀暈\nHighCPUUsage payment-service analysis with evidence summary.");
+
+ assertEquals("# 闁告稑锕ㄩ鐔煎礆閸℃鈧粙骞庨妷銉﹀暈\nHighCPUUsage payment-service analysis with evidence summary.", session.getAnswer());
+ assertTrue(session.getSelfEvaluation().contains("aiops_rule_evaluation"));
+ verify(diagnosisSessionRepository).save(session);
+ }
+
+ @Test
+ void persistFinalReportSkipsBlankInput() {
+ service.persistFinalReport("aiops-session-001", " ");
+
+ verifyNoInteractions(diagnosisSessionRepository);
+ }
+
+ @Test
+ void backfillSessionMetricsUsesRealToolInvocationCount() {
+ DiagnosisSession session = DiagnosisSession.builder()
+ .sessionId("aiops-session-002")
+ .build();
+ AgentStep stepWithTool = AgentStep.builder()
+ .sessionId("aiops-session-002")
+ .hasToolCall(true)
+ .tokenCount(10)
+ .build();
+ AgentStep stepWithoutTool = AgentStep.builder()
+ .sessionId("aiops-session-002")
+ .hasToolCall(false)
+ .tokenCount(20)
+ .build();
+ when(agentStepRepository.findBySessionIdOrderByStepIndex("aiops-session-002"))
+ .thenReturn(List.of(stepWithTool, stepWithoutTool));
+ when(toolInvocationRepository.countBySessionId("aiops-session-002")).thenReturn(11L);
+
+ ReflectionTestUtils.invokeMethod(service, "backfillSessionMetrics", session);
+
+ assertEquals(2, session.getStepCount());
+ assertEquals(30, session.getTotalTokenCount());
+ assertEquals(11, session.getToolCallCount());
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/ChatServiceSequentialAgentTest.java b/src/test/java/com/superbiz/agent/service/ChatServiceSequentialAgentTest.java
index a3c5dad..5f8cbf0 100644
--- a/src/test/java/com/superbiz/agent/service/ChatServiceSequentialAgentTest.java
+++ b/src/test/java/com/superbiz/agent/service/ChatServiceSequentialAgentTest.java
@@ -1,5 +1,8 @@
package com.superbiz.agent.service;
+import com.alibaba.cloud.ai.graph.agent.ReactAgent;
+import com.alibaba.cloud.ai.graph.skills.registry.SkillRegistry;
+import com.alibaba.cloud.ai.graph.skills.registry.classpath.ClasspathSkillRegistry;
import com.superbiz.agent.agent.tool.DateTimeTools;
import com.superbiz.agent.agent.tool.QueryLogsTools;
import com.superbiz.agent.agent.tool.QueryMetricsTools;
@@ -7,6 +10,7 @@ import com.superbiz.agent.domain.entity.AgentStep;
import com.superbiz.agent.domain.entity.DiagnosisSession;
import com.superbiz.agent.repository.AgentStepRepository;
import com.superbiz.agent.repository.DiagnosisSessionRepository;
+import com.superbiz.agent.repository.ToolInvocationRepository;
import com.superbiz.agent.tool.LookupKnowledgeTool;
import com.superbiz.agent.tool.RetrievedDocTracker;
import org.junit.jupiter.api.Test;
@@ -24,6 +28,7 @@ import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -85,6 +90,73 @@ class ChatServiceSequentialAgentTest {
assertEquals(List.of("chat_planner", "chat_executor", "chat_verifier"), chatModel.agentCalls);
}
+ @Test
+ void executeChatComplexFallsBackToLowConfidenceWhenVerifierOutputMissing() throws Exception {
+ ChatService chatService = createChatService();
+ ScriptedChatModel chatModel = new ScriptedChatModel("", "");
+
+ ChatService.ChatResult result = chatService.executeChatComplex(
+ chatModel,
+ new ToolCallback[0],
+ "请分析订单支付超时的原因,并给出修复建议",
+ List.of(),
+ "sequential-missing-verifier-session"
+ );
+
+ assertTrue(result.answer().startsWith("以下结论基于当前已获取证据"));
+ assertEquals(List.of("chat_planner", "chat_executor", "chat_verifier", "chat_verifier"), chatModel.agentCalls);
+ }
+
+ @Test
+ void executeChatComplexFallsBackToLowConfidenceWhenVerifierJsonInvalid() throws Exception {
+ ChatService chatService = createChatService();
+ ScriptedChatModel chatModel = new ScriptedChatModel("not-json");
+
+ ChatService.ChatResult result = chatService.executeChatComplex(
+ chatModel,
+ new ToolCallback[0],
+ "请分析订单支付超时的原因,并给出修复建议",
+ List.of(),
+ "sequential-invalid-verifier-session"
+ );
+
+ assertTrue(result.answer().startsWith("以下结论基于当前已获取证据"));
+ assertEquals(List.of("chat_planner", "chat_executor", "chat_verifier"), chatModel.agentCalls);
+ }
+
+ @Test
+ void executeChatComplexRejectOutputDoesNotLeakExecutorAnswer() throws Exception {
+ ChatService chatService = createChatService();
+ ScriptedChatModel chatModel = new ScriptedChatModel("""
+ {
+ "verdict": "REJECT",
+ "groundedness_score": 0.0,
+ "critical_fact_count": 1,
+ "facts_checked": [
+ {
+ "fact": "payment timeout root cause",
+ "is_critical": true,
+ "verification": "contradicted",
+ "detail": "scripted contradiction",
+ "evidence_refs": []
+ }
+ ],
+ "rationale": "scripted reject"
+ }
+ """);
+
+ ChatService.ChatResult result = chatService.executeChatComplex(
+ chatModel,
+ new ToolCallback[0],
+ "请分析订单支付超时的原因,并给出修复建议",
+ List.of(),
+ "sequential-reject-session"
+ );
+
+ assertTrue(result.answer().startsWith("当前无法基于已获取证据生成可靠结论"));
+ assertFalse(result.answer().contains("EXECUTOR_FINAL_ANSWER"));
+ }
+
@Test
void executeChatComplexRunsPlannerExecutorVerifierInFixedOrder() throws Exception {
ChatService chatService = createChatService();
@@ -125,6 +197,56 @@ class ChatServiceSequentialAgentTest {
assertSame(queryMetricsTools, methodTools[3]);
}
+ @Test
+ void createReactAgentInjectsSkillCatalogThroughAlibabaHook() throws Exception {
+ ChatService chatService = createChatService();
+ ScriptedChatModel chatModel = new ScriptedChatModel();
+ SkillRegistry skillRegistry = ClasspathSkillRegistry.builder()
+ .classpathPath("skills")
+ .basePath("target/test-skills-cache")
+ .build();
+ ReflectionTestUtils.setField(chatService, "skillRegistry", skillRegistry);
+
+ ReactAgent agent = chatService.createReactAgent(chatModel, "BASE_TEST_PROMPT");
+ agent.call("diagnose mysql connection pool exhaustion");
+
+ assertTrue(chatModel.promptText.contains("BASE_TEST_PROMPT"));
+ assertTrue(chatModel.promptText.contains("## Skills System"));
+ assertTrue(chatModel.promptText.contains("diagnose-mysql-connection-pool"));
+ assertTrue(chatModel.promptText.contains("read_skill"));
+ }
+
+ @Test
+ void plannerGetsSkillMetadataAndExecutorGetsReadSkillTool() throws Exception {
+ ChatService chatService = createChatService();
+ ScriptedChatModel chatModel = new ScriptedChatModel();
+ SkillRegistry skillRegistry = ClasspathSkillRegistry.builder()
+ .classpathPath("skills")
+ .basePath("target/test-skills-cache")
+ .build();
+ ReflectionTestUtils.setField(chatService, "skillRegistry", skillRegistry);
+
+ chatService.executeChatComplex(
+ chatModel,
+ new ToolCallback[0],
+ "diagnose mysql connection pool exhaustion",
+ List.of(),
+ "planner-skill-metadata-session"
+ );
+
+ assertTrue(chatModel.plannerPromptText.contains("\"skill_catalog\""));
+ assertTrue(chatModel.plannerPromptText.contains("diagnose-mysql-connection-pool"));
+ assertTrue(chatModel.plannerPromptText.contains("\"selected_skill\""));
+ assertFalse(chatModel.plannerPromptText.contains("## Skills System"));
+ assertFalse(chatModel.plannerPromptText.contains("read_skill"));
+
+ assertTrue(chatModel.executorPromptText.contains("## Skills System"));
+ assertTrue(chatModel.executorPromptText.contains("diagnose-mysql-connection-pool"));
+ assertTrue(chatModel.executorPromptText.contains("read_skill"));
+ assertFalse(chatModel.verifierPromptText.contains("diagnose-mysql-connection-pool"));
+ assertFalse(chatModel.verifierPromptText.contains("read_skill"));
+ }
+
private ChatService createChatService() {
ChatService chatService = new ChatService();
@@ -143,6 +265,8 @@ class ChatServiceSequentialAgentTest {
});
when(agentStepRepository.findById(any())).thenReturn(Optional.of(new AgentStep()));
when(agentStepRepository.findBySessionIdOrderByStepIndex(anyString())).thenReturn(List.of());
+ ToolInvocationRepository toolInvocationRepository = mock(ToolInvocationRepository.class);
+ when(toolInvocationRepository.countBySessionId(anyString())).thenReturn(0L);
EvaluationService evaluationService = mock(EvaluationService.class);
RetrievedDocTracker retrievedDocTracker = mock(RetrievedDocTracker.class);
@@ -158,6 +282,7 @@ class ChatServiceSequentialAgentTest {
ReflectionTestUtils.setField(chatService, "queryLogsTools", new QueryLogsTools(mock(ToolInvocationRecorder.class)));
ReflectionTestUtils.setField(chatService, "diagnosisSessionRepository", diagnosisSessionRepository);
ReflectionTestUtils.setField(chatService, "agentStepRepository", agentStepRepository);
+ ReflectionTestUtils.setField(chatService, "toolInvocationRepository", toolInvocationRepository);
ReflectionTestUtils.setField(chatService, "evaluationService", evaluationService);
ReflectionTestUtils.setField(chatService, "retrievedDocTracker", retrievedDocTracker);
ReflectionTestUtils.setField(chatService, "knowledgeDomainService", knowledgeDomainService);
@@ -173,8 +298,12 @@ class ChatServiceSequentialAgentTest {
private static final class ScriptedChatModel implements ChatModel {
private final java.util.ArrayList agentCalls = new java.util.ArrayList<>();
private String promptText = "";
+ private String plannerPromptText = "";
+ private String executorPromptText = "";
+ private String verifierPromptText = "";
private boolean sawVerifierPrompt;
- private final String verifierOutput;
+ private final java.util.List verifierOutputs;
+ private int verifierOutputIndex;
private ScriptedChatModel() {
this("""
@@ -197,7 +326,11 @@ class ChatServiceSequentialAgentTest {
}
private ScriptedChatModel(String verifierOutput) {
- this.verifierOutput = verifierOutput;
+ this.verifierOutputs = java.util.List.of(verifierOutput);
+ }
+
+ private ScriptedChatModel(String... verifierOutputs) {
+ this.verifierOutputs = java.util.List.of(verifierOutputs);
}
@Override
@@ -206,14 +339,19 @@ class ChatServiceSequentialAgentTest {
String text;
if (promptText.contains("PLANNER_TEST_PROMPT")) {
agentCalls.add("chat_planner");
+ plannerPromptText = promptText;
text = "PLANNER_PLAN";
} else if (promptText.contains("EXECUTOR_TEST_PROMPT")) {
agentCalls.add("chat_executor");
+ executorPromptText = promptText;
text = "EXECUTOR_FINAL_ANSWER";
} else if (promptText.contains("VERIFIER_TEST_PROMPT")) {
agentCalls.add("chat_verifier");
+ verifierPromptText = promptText;
sawVerifierPrompt = true;
- text = verifierOutput;
+ int index = Math.min(verifierOutputIndex, verifierOutputs.size() - 1);
+ text = verifierOutputs.get(index);
+ verifierOutputIndex++;
} else {
text = "UNEXPECTED_PROMPT";
}
diff --git a/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java b/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java
index 061cc27..5ae5ec6 100644
--- a/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java
+++ b/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java
@@ -45,7 +45,7 @@ class DiagnosisTraceServiceTest {
.stepCount(2)
.toolCallCount(1)
.answer("restart payment gateway pool")
- .selfEvaluation("{\"verifier_evaluation\":{\"verdict\":\"PASS\"}}")
+ .selfEvaluation("{\"verifier_evaluation\":{\"verdict\":\"PASS\"},\"aiops_rule_evaluation\":{\"verdict\":\"WARN\"}}")
.feedback("useful")
.createdAt(now)
.updatedAt(now)
@@ -103,6 +103,7 @@ class DiagnosisTraceServiceTest {
assertEquals(1, response.getSummary().getPersistedToolCallCount());
assertEquals(1, response.getSummary().getReturnedToolCallCount());
assertTrue(response.getSummary().isHasVerifierEvaluation());
+ assertTrue(response.getSummary().isHasAiOpsRuleEvaluation());
assertTrue(response.getSummary().isHasFeedback());
}
diff --git a/src/test/java/com/superbiz/agent/service/KnowledgeIndexServiceTest.java b/src/test/java/com/superbiz/agent/service/KnowledgeIndexServiceTest.java
index 1a85961..71c90fe 100644
--- a/src/test/java/com/superbiz/agent/service/KnowledgeIndexServiceTest.java
+++ b/src/test/java/com/superbiz/agent/service/KnowledgeIndexServiceTest.java
@@ -98,6 +98,46 @@ class KnowledgeIndexServiceTest {
assertEquals(2, results.size());
}
+ @Test
+ void testAnalyzeQuery_returnsStructuredHint() {
+ KnowledgeEntry entry = KnowledgeEntry.builder()
+ .filePath("mysql.md")
+ .title("MySQL Doc")
+ .keywords(List.of("mysql", "connection pool"))
+ .category("database")
+ .build();
+
+ service.addToIndex(entry);
+
+ KnowledgeIndexService.L0Hint hint = service.analyzeQuery("mysql connection pool timeout");
+
+ assertEquals(1, hint.matches().size());
+ assertEquals(List.of("mysql", "connection pool"), hint.matchedKeywords());
+ assertEquals(List.of("database"), hint.domains());
+ assertEquals(List.of("mysql", "connection pool"), hint.entities());
+ assertEquals(List.of("MySQL Doc"), hint.titles());
+ assertEquals("database", hint.singleDomainOrNull());
+ }
+
+ @Test
+ void testAnalyzeQuery_multipleDomainsHasNoSingleDomain() {
+ service.addToIndex(KnowledgeEntry.builder()
+ .filePath("mysql.md")
+ .keywords(List.of("timeout"))
+ .category("database")
+ .build());
+ service.addToIndex(KnowledgeEntry.builder()
+ .filePath("api.md")
+ .keywords(List.of("timeout"))
+ .category("api")
+ .build());
+
+ KnowledgeIndexService.L0Hint hint = service.analyzeQuery("timeout");
+
+ assertEquals(2, hint.matches().size());
+ assertNull(hint.singleDomainOrNull());
+ }
+
@Test
void testExactMatch_noMatch() {
KnowledgeEntry entry = KnowledgeEntry.builder()
diff --git a/src/test/java/com/superbiz/agent/service/RagRetrievalSidecarComparisonServiceTest.java b/src/test/java/com/superbiz/agent/service/RagRetrievalSidecarComparisonServiceTest.java
new file mode 100644
index 0000000..63c7fe5
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/RagRetrievalSidecarComparisonServiceTest.java
@@ -0,0 +1,117 @@
+package com.superbiz.agent.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.superbiz.agent.config.RagSidecarProperties;
+import com.superbiz.agent.dto.ComparableRetrievalResult;
+import com.superbiz.agent.dto.RetrievalComparisonCase;
+import com.superbiz.agent.dto.RetrievalComparisonReport;
+import com.superbiz.agent.dto.SidecarRetrievalResponse;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class RagRetrievalSidecarComparisonServiceTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void compareWritesSeparateSidecarReports() throws Exception {
+ VectorSearchService vectorSearchService = mock(VectorSearchService.class);
+ SpringAiVectorStoreSidecarService sidecarService = mock(SpringAiVectorStoreSidecarService.class);
+ RagSidecarProperties properties = new RagSidecarProperties();
+ RetrievalResultNormalizer normalizer = new RetrievalResultNormalizer(new ObjectMapper());
+ RagRetrievalSidecarComparisonService comparisonService = new RagRetrievalSidecarComparisonService(
+ vectorSearchService,
+ sidecarService,
+ normalizer,
+ properties,
+ new ObjectMapper()
+ );
+
+ VectorSearchService.SearchResult current = new VectorSearchService.SearchResult();
+ current.setId("current-1");
+ current.setMetadata("{\"_source\":\"current.md\",\"breadcrumb\":\"A\",\"category\":\"api\"}");
+ current.setContent("current content");
+ current.setScore(0.1f);
+ when(vectorSearchService.searchSimilarDocuments("timeout", 3, "api"))
+ .thenReturn(List.of(current));
+ when(sidecarService.search("timeout", 3, "api"))
+ .thenReturn(SidecarRetrievalResponse.builder()
+ .enabled(true)
+ .available(true)
+ .status("available")
+ .results(List.of(ComparableRetrievalResult.builder()
+ .path("sidecar")
+ .rank(1)
+ .source("sidecar.md")
+ .breadcrumb("B")
+ .scoreLabel("similarity")
+ .scoreValue(0.9)
+ .build()))
+ .build());
+
+ RetrievalComparisonReport report = comparisonService.compare(List.of(
+ RetrievalComparisonCase.builder()
+ .caseId("case-1")
+ .scenario("aiops")
+ .query("timeout")
+ .category("api")
+ .build()
+ ), 3);
+
+ assertEquals(1, report.getCaseCount());
+ assertEquals("available", report.getSidecarStatus());
+ assertTrue(report.getResults().get(0).getDifferences().contains("top_source_differs"));
+ Path json = tempDir.resolve("sidecar.json");
+ Path markdown = tempDir.resolve("sidecar.md");
+ comparisonService.writeReports(report, json, markdown);
+
+ assertTrue(Files.readString(json).contains("\"sidecarStatus\""));
+ assertTrue(Files.readString(markdown).contains("RAG Sidecar Retrieval Comparison"));
+ }
+
+ @Test
+ void compareGoldenCasesLoadsExistingCaseShape() throws Exception {
+ VectorSearchService vectorSearchService = mock(VectorSearchService.class);
+ SpringAiVectorStoreSidecarService sidecarService = mock(SpringAiVectorStoreSidecarService.class);
+ RagRetrievalSidecarComparisonService comparisonService = new RagRetrievalSidecarComparisonService(
+ vectorSearchService,
+ sidecarService,
+ new RetrievalResultNormalizer(new ObjectMapper()),
+ new RagSidecarProperties(),
+ new ObjectMapper()
+ );
+ when(vectorSearchService.searchSimilarDocuments("query", 2, null)).thenReturn(List.of());
+ when(sidecarService.search("query", 2, null))
+ .thenReturn(SidecarRetrievalResponse.builder()
+ .enabled(false)
+ .available(false)
+ .status("disabled")
+ .results(List.of())
+ .build());
+ Path cases = tempDir.resolve("cases.json");
+ Files.writeString(cases, """
+ {
+ "topK": 2,
+ "cases": [
+ {"caseId": "case-1", "scenario": "chat", "query": "query"}
+ ]
+ }
+ """);
+
+ RetrievalComparisonReport report = comparisonService.compareGoldenCases(cases);
+
+ assertEquals(1, report.getCaseCount());
+ assertEquals(2, report.getTopK());
+ assertEquals("disabled", report.getSidecarStatus());
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/RetrievalResultNormalizerTest.java b/src/test/java/com/superbiz/agent/service/RetrievalResultNormalizerTest.java
new file mode 100644
index 0000000..2c3e62f
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/RetrievalResultNormalizerTest.java
@@ -0,0 +1,60 @@
+package com.superbiz.agent.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.superbiz.agent.dto.ComparableRetrievalResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.document.Document;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class RetrievalResultNormalizerTest {
+
+ private final RetrievalResultNormalizer normalizer = new RetrievalResultNormalizer(new ObjectMapper());
+
+ @Test
+ void fromCurrentParsesMetadataAndLabelsDistanceScore() {
+ VectorSearchService.SearchResult result = new VectorSearchService.SearchResult();
+ result.setId("vec-1");
+ result.setMetadata("{\"docId\":\"doc-1\",\"_source\":\"docs/api.md\",\"title\":\"API\",\"breadcrumb\":\"A > B\",\"category\":\"api\"}");
+ result.setContent("abcdef");
+ result.setScore(0.25f);
+
+ ComparableRetrievalResult comparable = normalizer.fromCurrent(result, 1, 3);
+
+ assertEquals("current", comparable.getPath());
+ assertEquals("docs/api.md", comparable.getSource());
+ assertEquals("doc-1", comparable.getDocId());
+ assertEquals("API", comparable.getTitle());
+ assertEquals("A > B", comparable.getBreadcrumb());
+ assertEquals("api", comparable.getCategory());
+ assertEquals("abc...", comparable.getContentPreview());
+ assertEquals("l2_distance", comparable.getScoreLabel());
+ assertEquals(0.25, comparable.getScoreValue(), 0.0001);
+ }
+
+ @Test
+ void fromSidecarNormalizesDocumentMetadataAndLabelsSimilarityScore() {
+ Document document = Document.builder()
+ .id("doc-vector")
+ .text("sidecar content")
+ .metadata(Map.of(
+ "docId", "doc-2",
+ "_source", "docs/sidecar.md",
+ "title", "Sidecar",
+ "breadcrumb", "Root > Sidecar",
+ "category", "rag"
+ ))
+ .score(0.91)
+ .build();
+
+ ComparableRetrievalResult comparable = normalizer.fromSidecar(document, 2, 100);
+
+ assertEquals("sidecar", comparable.getPath());
+ assertEquals(2, comparable.getRank());
+ assertEquals("docs/sidecar.md", comparable.getSource());
+ assertEquals("similarity", comparable.getScoreLabel());
+ assertEquals(0.91, comparable.getScoreValue(), 0.0001);
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/SkillCatalogServiceTest.java b/src/test/java/com/superbiz/agent/service/SkillCatalogServiceTest.java
new file mode 100644
index 0000000..8fa2006
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/SkillCatalogServiceTest.java
@@ -0,0 +1,44 @@
+package com.superbiz.agent.service;
+
+import com.alibaba.cloud.ai.graph.agent.hook.skills.ReadSkillTool;
+import com.alibaba.cloud.ai.graph.skills.registry.SkillRegistry;
+import com.superbiz.agent.config.SkillConfig;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SkillCatalogServiceTest {
+
+ @Test
+ void loadsDiagnosisSkillsFromClasspathRegistry() {
+ SkillRegistry registry = newRegistry();
+
+ assertEquals(1, registry.size());
+ assertTrue(registry.contains("diagnose-mysql-connection-pool"));
+ }
+
+ @Test
+ void readSkillReturnsFullInstructionsFromOfficialTool() {
+ ReadSkillTool tool = new ReadSkillTool(newRegistry());
+
+ String skill = tool.apply(new ReadSkillTool.ReadSkillRequest("diagnose-mysql-connection-pool"), null);
+
+ assertTrue(skill.contains("## Workflow"));
+ assertTrue(skill.contains("query_logs"));
+ assertTrue(skill.contains("Fixed diagnosis case: `mysql-pool-exhausted`"));
+ }
+
+ @Test
+ void readSkillToolReturnsUnknownSkillError() {
+ ReadSkillTool tool = new ReadSkillTool(newRegistry());
+
+ String result = tool.apply(new ReadSkillTool.ReadSkillRequest("missing-skill"), null);
+
+ assertTrue(result.contains("Skill not found: missing-skill"));
+ }
+
+ private SkillRegistry newRegistry() {
+ return new SkillConfig().skillRegistry();
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/SpringAiVectorStoreSidecarServiceTest.java b/src/test/java/com/superbiz/agent/service/SpringAiVectorStoreSidecarServiceTest.java
new file mode 100644
index 0000000..309ae85
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/SpringAiVectorStoreSidecarServiceTest.java
@@ -0,0 +1,55 @@
+package com.superbiz.agent.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.superbiz.agent.config.RagSidecarProperties;
+import com.superbiz.agent.dto.SidecarRetrievalResponse;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.beans.factory.ObjectProvider;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class SpringAiVectorStoreSidecarServiceTest {
+
+ @Test
+ void disabledSidecarDoesNotRequestVectorStore() {
+ RagSidecarProperties properties = new RagSidecarProperties();
+ ObjectProvider provider = mock(ObjectProvider.class);
+ SpringAiVectorStoreSidecarService service = new SpringAiVectorStoreSidecarService(
+ properties,
+ provider,
+ new RetrievalResultNormalizer(new ObjectMapper())
+ );
+
+ SidecarRetrievalResponse response = service.search("query", 3, null);
+
+ assertFalse(response.isEnabled());
+ assertFalse(response.isAvailable());
+ assertEquals("disabled", response.getStatus());
+ verify(provider, never()).getIfAvailable();
+ }
+
+ @Test
+ void enabledSidecarReportsMissingVectorStore() {
+ RagSidecarProperties properties = new RagSidecarProperties();
+ properties.setEnabled(true);
+ ObjectProvider provider = mock(ObjectProvider.class);
+ when(provider.getIfAvailable()).thenReturn(null);
+ SpringAiVectorStoreSidecarService service = new SpringAiVectorStoreSidecarService(
+ properties,
+ provider,
+ new RetrievalResultNormalizer(new ObjectMapper())
+ );
+
+ SidecarRetrievalResponse response = service.search("query", 3, "api");
+
+ assertEquals("missing_vector_store", response.getStatus());
+ assertFalse(response.isAvailable());
+ assertEquals(0, response.getResults().size());
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/ToolInvocationRecorderTest.java b/src/test/java/com/superbiz/agent/service/ToolInvocationRecorderTest.java
new file mode 100644
index 0000000..54abfb3
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/ToolInvocationRecorderTest.java
@@ -0,0 +1,151 @@
+package com.superbiz.agent.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.superbiz.agent.domain.entity.ToolInvocation;
+import com.superbiz.agent.dto.EvidenceBlock;
+import com.superbiz.agent.repository.ToolInvocationRepository;
+import com.superbiz.agent.util.SessionContextHolder;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class ToolInvocationRecorderTest {
+
+ @Test
+ void recordEvidenceToolPreservesNoEvidenceSemantics() {
+ ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
+ when(repository.save(any(ToolInvocation.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ ToolInvocationRecorder recorder = new ToolInvocationRecorder(repository, new ObjectMapper());
+ SessionContextHolder.setSessionId("recorder-test-session");
+
+ try {
+ recorder.recordEvidenceTool(
+ "query_logs",
+ Map.of("query", "timeout"),
+ "{\"success\":false,\"message\":\"未找到匹配的日志\"}",
+ true,
+ System.currentTimeMillis() - 10,
+ null,
+ "application-logs",
+ ToolInvocationRecorder.EVIDENCE_STATUS_NO_EVIDENCE,
+ Map.of("log_topic", "application-logs")
+ );
+ } finally {
+ SessionContextHolder.clear();
+ }
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ToolInvocation.class);
+ verify(repository).save(captor.capture());
+ ToolInvocation saved = captor.getValue();
+
+ assertEquals("query_logs", saved.getToolName());
+ assertEquals(Boolean.TRUE, saved.getSuccess());
+ assertTrue(saved.getRetrievalDetails().contains("\"evidence_status\":\"no_evidence\""));
+ assertTrue(saved.getRetrievalDetails().contains("\"retrieved_domains\":[\"application-logs\"]"));
+ }
+
+ @Test
+ void recordLookupKnowledgePreservesRetrievalSpecificFields() {
+ ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
+ when(repository.save(any(ToolInvocation.class))).thenAnswer(invocation -> invocation.getArgument(0));
+ ToolInvocationRecorder recorder = new ToolInvocationRecorder(repository, new ObjectMapper());
+ SessionContextHolder.setSessionId("lookup-recorder-session");
+
+ ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.builder()
+ .query("ERR_TIMEOUT")
+ .outputPreview("matched payment doc")
+ .outputLength(18)
+ .retrievalLayer("L0")
+ .l0MatchCount(1)
+ .l1MatchCount(null)
+ .truncated(false)
+ .relevanceLevel("PRECISE")
+ .completenessHint("already precise")
+ .domain("payment")
+ .dedupReason("doc_retrieved")
+ .durationMs(42)
+ .success(true)
+ .evidenceStatus(ToolInvocationRecorder.EVIDENCE_STATUS_DEDUPED)
+ .l0Titles(List.of("payment/errors.md"))
+ .l0MatchedKeywords(List.of("ERR_TIMEOUT"))
+ .l0Domains(List.of("payment"))
+ .l0Entities(List.of("ERR_TIMEOUT"))
+ .evidenceCandidateCount(2)
+ .evidenceBlockCount(1)
+ .evidenceBlocks(List.of(Map.of(
+ "source", "payment/errors.md",
+ "title", "payment/errors.md",
+ "retrieval_layer", "L0+L1",
+ "hit_reasons", List.of("l0_keywords:ERR_TIMEOUT", "semantic_rank:1")
+ )))
+ .build();
+
+ try {
+ recorder.recordLookupKnowledge(record);
+ } finally {
+ SessionContextHolder.clear();
+ }
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(ToolInvocation.class);
+ verify(repository).save(captor.capture());
+ ToolInvocation saved = captor.getValue();
+
+ assertEquals("lookup_knowledge", saved.getToolName());
+ assertEquals("PRECISE", saved.getRelevanceLevel());
+ assertEquals("doc_retrieved", saved.getDedupReason());
+ assertTrue(saved.getRetrievalDetails().contains("\"evidence_status\":\"deduped\""));
+ assertTrue(saved.getRetrievalDetails().contains("\"retrieved_domains\":[\"payment\"]"));
+ assertTrue(saved.getRetrievalDetails().contains("\"l0_matched_keywords\":[\"ERR_TIMEOUT\"]"));
+ assertTrue(saved.getRetrievalDetails().contains("\"l0_domains\":[\"payment\"]"));
+ assertTrue(saved.getRetrievalDetails().contains("\"l0_entities\":[\"ERR_TIMEOUT\"]"));
+ assertTrue(saved.getRetrievalDetails().contains("\"evidence_candidate_count\":2"));
+ assertTrue(saved.getRetrievalDetails().contains("\"evidence_block_count\":1"));
+ assertTrue(saved.getRetrievalDetails().contains("\"evidence_blocks\""));
+ }
+
+ @Test
+ void lookupKnowledgeRecordFromSummarizesEvidenceBlocks() {
+ EvidenceBlock block = EvidenceBlock.builder()
+ .source("doc.md")
+ .title("Doc")
+ .breadcrumb("A > B")
+ .retrievalLayer("L1")
+ .score(0.42)
+ .hitReasons(List.of("semantic_rank:1"))
+ .content("x".repeat(220))
+ .build();
+
+ com.superbiz.agent.dto.LookupResult result = com.superbiz.agent.dto.LookupResult.builder()
+ .found(true)
+ .evidenceCandidateCount(3)
+ .evidenceBlockCount(1)
+ .evidenceBlocks(List.of(block))
+ .build();
+
+ ToolInvocationRecorder.LookupKnowledgeRecord record = ToolInvocationRecorder.LookupKnowledgeRecord.from(
+ "query",
+ KnowledgeIndexService.L0Hint.empty(),
+ List.of(),
+ false,
+ result,
+ null,
+ null,
+ 10,
+ -1
+ );
+
+ assertEquals(3, record.evidenceCandidateCount());
+ assertEquals(1, record.evidenceBlockCount());
+ assertEquals(1, record.evidenceBlocks().size());
+ assertTrue(String.valueOf(record.evidenceBlocks().get(0).get("content_preview")).endsWith("..."));
+ }
+}
diff --git a/src/test/java/com/superbiz/agent/service/ToolTraceSummaryServiceTest.java b/src/test/java/com/superbiz/agent/service/ToolTraceSummaryServiceTest.java
new file mode 100644
index 0000000..0f158f7
--- /dev/null
+++ b/src/test/java/com/superbiz/agent/service/ToolTraceSummaryServiceTest.java
@@ -0,0 +1,75 @@
+package com.superbiz.agent.service;
+
+import com.superbiz.agent.domain.entity.ToolInvocation;
+import com.superbiz.agent.repository.ToolInvocationRepository;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ToolTraceSummaryServiceTest {
+
+ @Test
+ void buildVerifierTraceSummaryTreatsNoEvidenceAsGapWithoutLosingSuccessfulEvidence() {
+ ToolInvocationRepository repository = mock(ToolInvocationRepository.class);
+ when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of(
+ ToolInvocation.builder()
+ .id(1L)
+ .sessionId("session-1")
+ .toolName("query_logs")
+ .inputParams("{\"query\":\"timeout\"}")
+ .outputPreview("payment timeout stack trace")
+ .retrievalDetails("{\"retrieved_domains\":[\"application-logs\"],\"evidence_status\":\"supported\"}")
+ .success(true)
+ .build(),
+ ToolInvocation.builder()
+ .id(2L)
+ .sessionId("session-1")
+ .toolName("query_logs")
+ .inputParams("{\"query\":\"timeout\"}")
+ .outputPreview("{\"success\":false,\"message\":\"未找到匹配的日志\"}")
+ .retrievalDetails("{\"retrieved_domains\":[\"application-logs\"],\"evidence_status\":\"no_evidence\"}")
+ .success(true)
+ .build(),
+ ToolInvocation.builder()
+ .id(3L)
+ .sessionId("session-1")
+ .toolName("query_metrics")
+ .inputParams("{\"query\":\"active_prometheus_alerts\"}")
+ .errorMessage("prometheus timeout")
+ .retrievalDetails("{\"retrieved_domains\":[\"prometheus_alerts\"],\"evidence_status\":\"failed\"}")
+ .success(false)
+ .build()
+ ));
+
+ ToolTraceSummaryService service = new ToolTraceSummaryService(repository);
+
+ List