Merge branch 'emdash/shy-items-fry-f4zze' into refactor/mvp1.0
# Conflicts: # mvp/issues/README.md
This commit is contained in:
@@ -131,13 +131,15 @@ public class QueryLogsTools {
|
||||
output.setMessage(String.format("共有 %d 个可用的日志主题。建议使用默认地域 'ap-guangzhou' 或省略 region 参数", topics.size()));
|
||||
|
||||
String response = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(output);
|
||||
recordInvocation(startTime, "get_available_log_topics", null, null, null, response, true, null, "logs");
|
||||
recordInvocation("get_available_log_topics", startTime, "get_available_log_topics", null, null, null,
|
||||
response, true, null, "logs", ToolInvocationRecorder.EVIDENCE_STATUS_SUPPORTED);
|
||||
return response;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("获取日志主题列表失败", e);
|
||||
String response = "{\"success\":false,\"message\":\"获取日志主题列表失败: " + e.getMessage() + "\"}";
|
||||
recordInvocation(startTime, "get_available_log_topics", null, null, null, response, false, e.getMessage(), "logs");
|
||||
recordInvocation("get_available_log_topics", startTime, "get_available_log_topics", null, null, null,
|
||||
response, false, e.getMessage(), "logs", ToolInvocationRecorder.EVIDENCE_STATUS_FAILED);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -191,8 +193,9 @@ public class QueryLogsTools {
|
||||
} else {
|
||||
// 真实模式:调用 CLS API(这里预留接口,后续实现)
|
||||
String response = buildErrorResponse("CLS 真实查询尚未实现,请启用 mock 模式进行测试");
|
||||
recordInvocation(startTime, safeQuery, region, logTopic, actualLimit, response, false,
|
||||
"CLS 真实查询尚未实现,请启用 mock 模式进行测试", normalizeTopicDomain(logTopic));
|
||||
recordInvocation("query_logs", startTime, safeQuery, region, logTopic, actualLimit, response, false,
|
||||
"CLS 真实查询尚未实现,请启用 mock 模式进行测试", normalizeTopicDomain(logTopic),
|
||||
ToolInvocationRecorder.EVIDENCE_STATUS_FAILED);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -208,23 +211,26 @@ public class QueryLogsTools {
|
||||
|
||||
String jsonResult = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(output);
|
||||
logger.info("日志查询完成: 找到 {} 条日志", logEntries.size());
|
||||
recordInvocation(startTime, safeQuery, region, logTopic, actualLimit, jsonResult,
|
||||
!logEntries.isEmpty(), logEntries.isEmpty() ? "未找到匹配的日志" : null,
|
||||
normalizeTopicDomain(logTopic));
|
||||
recordInvocation("query_logs", startTime, safeQuery, region, logTopic, actualLimit, jsonResult,
|
||||
true, null, normalizeTopicDomain(logTopic),
|
||||
logEntries.isEmpty()
|
||||
? ToolInvocationRecorder.EVIDENCE_STATUS_NO_EVIDENCE
|
||||
: ToolInvocationRecorder.EVIDENCE_STATUS_SUPPORTED);
|
||||
|
||||
return jsonResult;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("查询日志失败", e);
|
||||
String response = buildErrorResponse("查询失败: " + e.getMessage());
|
||||
recordInvocation(startTime, safeQuery, region, logTopic, actualLimit, response, false,
|
||||
e.getMessage(), normalizeTopicDomain(logTopic));
|
||||
recordInvocation("query_logs", startTime, safeQuery, region, logTopic, actualLimit, response, false,
|
||||
e.getMessage(), normalizeTopicDomain(logTopic), ToolInvocationRecorder.EVIDENCE_STATUS_FAILED);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private void recordInvocation(long startTime, String query, String region, String logTopic, Integer limit,
|
||||
String output, boolean success, String errorMessage, String topicDomain) {
|
||||
private void recordInvocation(String toolName, long startTime, String query, String region, String logTopic, Integer limit,
|
||||
String output, boolean success, String errorMessage, String topicDomain,
|
||||
String evidenceStatus) {
|
||||
Map<String, Object> input = new HashMap<>();
|
||||
input.put("query", query == null || query.isBlank() ? "DEFAULT_QUERY" : query);
|
||||
if (region != null) {
|
||||
@@ -239,13 +245,15 @@ public class QueryLogsTools {
|
||||
input.put("mock_enabled", mockEnabled);
|
||||
|
||||
toolInvocationRecorder.recordEvidenceTool(
|
||||
"query_logs",
|
||||
toolName,
|
||||
input,
|
||||
output,
|
||||
success,
|
||||
startTime,
|
||||
errorMessage,
|
||||
topicDomain
|
||||
topicDomain,
|
||||
evidenceStatus,
|
||||
Map.of("log_topic", logTopic == null ? "" : logTopic)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ public class QueryMetricsTools {
|
||||
|
||||
if (!"success".equals(result.getStatus())) {
|
||||
String response = buildErrorResponse("Prometheus API 返回非成功状态: " + result.getStatus(), result.getError());
|
||||
recordInvocation(startTime, response, false, result.getError());
|
||||
recordInvocation(startTime, response, false, result.getError(), ToolInvocationRecorder.EVIDENCE_STATUS_FAILED);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -119,19 +119,22 @@ public class QueryMetricsTools {
|
||||
|
||||
String jsonResult = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(output);
|
||||
logger.info("Prometheus 告警查询完成: 找到 {} 个告警", simplifiedAlerts.size());
|
||||
recordInvocation(startTime, jsonResult, true, null);
|
||||
recordInvocation(startTime, jsonResult, true, null,
|
||||
simplifiedAlerts.isEmpty()
|
||||
? ToolInvocationRecorder.EVIDENCE_STATUS_NO_EVIDENCE
|
||||
: ToolInvocationRecorder.EVIDENCE_STATUS_SUPPORTED);
|
||||
|
||||
return jsonResult;
|
||||
|
||||
} catch (Exception e) {
|
||||
logger.error("查询 Prometheus 告警失败", e);
|
||||
String response = buildErrorResponse("查询失败", e.getMessage());
|
||||
recordInvocation(startTime, response, false, e.getMessage());
|
||||
recordInvocation(startTime, response, false, e.getMessage(), ToolInvocationRecorder.EVIDENCE_STATUS_FAILED);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private void recordInvocation(long startTime, String output, boolean success, String errorMessage) {
|
||||
private void recordInvocation(long startTime, String output, boolean success, String errorMessage, String evidenceStatus) {
|
||||
toolInvocationRecorder.recordEvidenceTool(
|
||||
"query_metrics",
|
||||
Map.of("query", "active_prometheus_alerts", "mock_enabled", mockEnabled),
|
||||
@@ -139,7 +142,9 @@ public class QueryMetricsTools {
|
||||
success,
|
||||
startTime,
|
||||
errorMessage,
|
||||
"prometheus_alerts"
|
||||
"prometheus_alerts",
|
||||
evidenceStatus,
|
||||
Map.of("metric_family", "prometheus_alerts")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class DiagnosisEvalBaselineDiffer {
|
||||
|
||||
private static final String REGRESSION = "REGRESSION";
|
||||
private static final String IMPROVEMENT = "IMPROVEMENT";
|
||||
private static final String CHANGED = "CHANGED";
|
||||
|
||||
public DiagnosisEvalDiffReport compare(DiagnosisEvalReport baseline, DiagnosisEvalReport current) {
|
||||
List<DiagnosisEvalDiffItem> items = new ArrayList<>();
|
||||
|
||||
compareDouble(items, "aggregate", null, "passRate",
|
||||
baseline.getPassRate(), current.getPassRate(), true);
|
||||
compareDouble(items, "aggregate", null, "averageToolCallCount",
|
||||
baseline.getAverageToolCallCount(), current.getAverageToolCallCount(), false);
|
||||
compareDouble(items, "aggregate", null, "averageDurationMs",
|
||||
baseline.getAverageDurationMs(), current.getAverageDurationMs(), false);
|
||||
compareVerdictDistribution(items, baseline.getVerdictDistribution(), current.getVerdictDistribution());
|
||||
compareCases(items, safeResults(baseline), safeResults(current));
|
||||
|
||||
int regressionCount = countType(items, REGRESSION);
|
||||
int improvementCount = countType(items, IMPROVEMENT);
|
||||
int changedCount = countType(items, CHANGED);
|
||||
|
||||
return DiagnosisEvalDiffReport.builder()
|
||||
.baselineTotalCases(baseline.getTotalCases())
|
||||
.currentTotalCases(current.getTotalCases())
|
||||
.baselinePassedCases(baseline.getPassedCases())
|
||||
.currentPassedCases(current.getPassedCases())
|
||||
.baselinePassRate(baseline.getPassRate())
|
||||
.currentPassRate(current.getPassRate())
|
||||
.regressionCount(regressionCount)
|
||||
.improvementCount(improvementCount)
|
||||
.changedCount(changedCount)
|
||||
.hasRegression(regressionCount > 0)
|
||||
.items(items)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void compareVerdictDistribution(List<DiagnosisEvalDiffItem> items,
|
||||
Map<String, Long> baseline,
|
||||
Map<String, Long> current) {
|
||||
Set<String> verdicts = new LinkedHashSet<>();
|
||||
verdicts.addAll(safeMap(baseline).keySet());
|
||||
verdicts.addAll(safeMap(current).keySet());
|
||||
for (String verdict : verdicts) {
|
||||
long baselineCount = safeMap(baseline).getOrDefault(verdict, 0L);
|
||||
long currentCount = safeMap(current).getOrDefault(verdict, 0L);
|
||||
if (baselineCount != currentCount) {
|
||||
items.add(item(CHANGED, "aggregate", null, "verdictDistribution." + verdict,
|
||||
String.valueOf(baselineCount), String.valueOf(currentCount),
|
||||
(double) currentCount - baselineCount,
|
||||
"verdict count changed for " + verdict));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void compareCases(List<DiagnosisEvalDiffItem> items,
|
||||
List<DiagnosisEvalResult> baselineResults,
|
||||
List<DiagnosisEvalResult> currentResults) {
|
||||
Map<String, DiagnosisEvalResult> baselineById = byCaseId(baselineResults);
|
||||
Map<String, DiagnosisEvalResult> currentById = byCaseId(currentResults);
|
||||
Set<String> caseIds = new LinkedHashSet<>();
|
||||
caseIds.addAll(baselineById.keySet());
|
||||
caseIds.addAll(currentById.keySet());
|
||||
|
||||
for (String caseId : caseIds) {
|
||||
DiagnosisEvalResult baseline = baselineById.get(caseId);
|
||||
DiagnosisEvalResult current = currentById.get(caseId);
|
||||
if (baseline == null) {
|
||||
items.add(item(CHANGED, "case", caseId, "casePresence",
|
||||
"missing", "present", null, "new case appears in current report"));
|
||||
continue;
|
||||
}
|
||||
if (current == null) {
|
||||
items.add(item(REGRESSION, "case", caseId, "casePresence",
|
||||
"present", "missing", null, "baseline case is missing from current report"));
|
||||
continue;
|
||||
}
|
||||
|
||||
comparePassState(items, baseline, current);
|
||||
compareVerdict(items, baseline, current);
|
||||
compareInteger(items, caseId, "matchedKeywordCount",
|
||||
baseline.getMatchedKeywordCount(), current.getMatchedKeywordCount(), true);
|
||||
compareInteger(items, caseId, "toolCallCount",
|
||||
baseline.getToolCallCount(), current.getToolCallCount(), false);
|
||||
compareInteger(items, caseId, "durationMs",
|
||||
baseline.getDurationMs(), current.getDurationMs(), false);
|
||||
compareEvidenceCoverage(items, baseline, current);
|
||||
}
|
||||
}
|
||||
|
||||
private void comparePassState(List<DiagnosisEvalDiffItem> items,
|
||||
DiagnosisEvalResult baseline,
|
||||
DiagnosisEvalResult current) {
|
||||
if (baseline.isPassed() == current.isPassed()) {
|
||||
return;
|
||||
}
|
||||
String type = baseline.isPassed() ? REGRESSION : IMPROVEMENT;
|
||||
items.add(item(type, "case", baseline.getCaseId(), "passed",
|
||||
String.valueOf(baseline.isPassed()), String.valueOf(current.isPassed()), null,
|
||||
baseline.getCaseId() + " pass state changed"));
|
||||
}
|
||||
|
||||
private void compareVerdict(List<DiagnosisEvalDiffItem> items,
|
||||
DiagnosisEvalResult baseline,
|
||||
DiagnosisEvalResult current) {
|
||||
if (Objects.equals(baseline.getVerdict(), current.getVerdict())) {
|
||||
return;
|
||||
}
|
||||
int baselineRank = verdictRank(baseline.getVerdict());
|
||||
int currentRank = verdictRank(current.getVerdict());
|
||||
String type = currentRank < baselineRank ? REGRESSION : currentRank > baselineRank ? IMPROVEMENT : CHANGED;
|
||||
items.add(item(type, "case", baseline.getCaseId(), "verdict",
|
||||
value(baseline.getVerdict()), value(current.getVerdict()), (double) currentRank - baselineRank,
|
||||
baseline.getCaseId() + " verdict changed"));
|
||||
}
|
||||
|
||||
private void compareEvidenceCoverage(List<DiagnosisEvalDiffItem> items,
|
||||
DiagnosisEvalResult baseline,
|
||||
DiagnosisEvalResult current) {
|
||||
Set<String> tools = new LinkedHashSet<>();
|
||||
tools.addAll(safeMap(baseline.getEvidenceCoverage()).keySet());
|
||||
tools.addAll(safeMap(current.getEvidenceCoverage()).keySet());
|
||||
for (String tool : tools) {
|
||||
boolean baselineCovered = Boolean.TRUE.equals(safeMap(baseline.getEvidenceCoverage()).get(tool));
|
||||
boolean currentCovered = Boolean.TRUE.equals(safeMap(current.getEvidenceCoverage()).get(tool));
|
||||
if (baselineCovered == currentCovered) {
|
||||
continue;
|
||||
}
|
||||
String type = baselineCovered ? REGRESSION : IMPROVEMENT;
|
||||
items.add(item(type, "case", baseline.getCaseId(), "evidenceCoverage." + tool,
|
||||
String.valueOf(baselineCovered), String.valueOf(currentCovered), null,
|
||||
baseline.getCaseId() + " evidence coverage changed for " + tool));
|
||||
}
|
||||
}
|
||||
|
||||
private void compareDouble(List<DiagnosisEvalDiffItem> items,
|
||||
String scope,
|
||||
String caseId,
|
||||
String metric,
|
||||
double baseline,
|
||||
double current,
|
||||
boolean higherIsBetter) {
|
||||
if (Double.compare(baseline, current) == 0) {
|
||||
return;
|
||||
}
|
||||
double delta = current - baseline;
|
||||
String type = classifyDelta(delta, higherIsBetter);
|
||||
items.add(item(type, scope, caseId, metric,
|
||||
String.valueOf(baseline), String.valueOf(current), delta,
|
||||
metric + " changed"));
|
||||
}
|
||||
|
||||
private void compareInteger(List<DiagnosisEvalDiffItem> items,
|
||||
String caseId,
|
||||
String metric,
|
||||
Integer baseline,
|
||||
Integer current,
|
||||
boolean higherIsBetter) {
|
||||
if (Objects.equals(baseline, current)) {
|
||||
return;
|
||||
}
|
||||
if (baseline == null || current == null) {
|
||||
items.add(item(CHANGED, "case", caseId, metric,
|
||||
value(baseline), value(current), null, caseId + " " + metric + " changed"));
|
||||
return;
|
||||
}
|
||||
int delta = current - baseline;
|
||||
items.add(item(classifyDelta(delta, higherIsBetter), "case", caseId, metric,
|
||||
String.valueOf(baseline), String.valueOf(current), (double) delta,
|
||||
caseId + " " + metric + " changed"));
|
||||
}
|
||||
|
||||
private String classifyDelta(double delta, boolean higherIsBetter) {
|
||||
if (delta == 0.0) {
|
||||
return CHANGED;
|
||||
}
|
||||
boolean improved = higherIsBetter ? delta > 0 : delta < 0;
|
||||
return improved ? IMPROVEMENT : REGRESSION;
|
||||
}
|
||||
|
||||
private DiagnosisEvalDiffItem item(String type,
|
||||
String scope,
|
||||
String caseId,
|
||||
String metric,
|
||||
String baselineValue,
|
||||
String currentValue,
|
||||
Double delta,
|
||||
String message) {
|
||||
return DiagnosisEvalDiffItem.builder()
|
||||
.type(type)
|
||||
.scope(scope)
|
||||
.caseId(caseId)
|
||||
.metric(metric)
|
||||
.baselineValue(baselineValue)
|
||||
.currentValue(currentValue)
|
||||
.delta(delta)
|
||||
.message(message)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Map<String, DiagnosisEvalResult> byCaseId(List<DiagnosisEvalResult> results) {
|
||||
return results.stream()
|
||||
.sorted(Comparator.comparing(DiagnosisEvalResult::getCaseId))
|
||||
.collect(Collectors.toMap(
|
||||
DiagnosisEvalResult::getCaseId,
|
||||
Function.identity(),
|
||||
(left, right) -> right,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
private List<DiagnosisEvalResult> safeResults(DiagnosisEvalReport report) {
|
||||
return report.getResults() == null ? List.of() : report.getResults();
|
||||
}
|
||||
|
||||
private <T> Map<String, T> safeMap(Map<String, T> value) {
|
||||
return value == null ? Map.of() : value;
|
||||
}
|
||||
|
||||
private int countType(List<DiagnosisEvalDiffItem> items, String type) {
|
||||
return (int) items.stream().filter(item -> type.equals(item.getType())).count();
|
||||
}
|
||||
|
||||
private int verdictRank(String verdict) {
|
||||
if ("PASS".equals(verdict)) {
|
||||
return 3;
|
||||
}
|
||||
if ("LOW_CONFID".equals(verdict)) {
|
||||
return 2;
|
||||
}
|
||||
if ("REJECT".equals(verdict)) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private String value(Object value) {
|
||||
return value == null ? "-" : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DiagnosisEvalCase {
|
||||
|
||||
private String id;
|
||||
private String title;
|
||||
private String question;
|
||||
private String traceFixture;
|
||||
private List<String> expectedRootCauseKeywords;
|
||||
private Integer minKeywordMatches;
|
||||
private List<String> requiredEvidenceTools;
|
||||
private List<String> allowedVerdicts;
|
||||
private List<String> forbiddenAnswerKeywords;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DiagnosisEvalDiffItem {
|
||||
|
||||
private String type;
|
||||
private String scope;
|
||||
private String caseId;
|
||||
private String metric;
|
||||
private String baselineValue;
|
||||
private String currentValue;
|
||||
private Double delta;
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DiagnosisEvalDiffReport {
|
||||
|
||||
private int baselineTotalCases;
|
||||
private int currentTotalCases;
|
||||
private int baselinePassedCases;
|
||||
private int currentPassedCases;
|
||||
private double baselinePassRate;
|
||||
private double currentPassRate;
|
||||
private int regressionCount;
|
||||
private int improvementCount;
|
||||
private int changedCount;
|
||||
private boolean hasRegression;
|
||||
private List<DiagnosisEvalDiffItem> items;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
public class DiagnosisEvalDiffReportWriter {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public DiagnosisEvalDiffReportWriter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public void writeJson(DiagnosisEvalDiffReport report, Path outputFile) throws IOException {
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
objectMapper.writerWithDefaultPrettyPrinter().writeValue(outputFile.toFile(), report);
|
||||
}
|
||||
|
||||
public void writeMarkdown(DiagnosisEvalDiffReport report, Path outputFile) throws IOException {
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
Files.writeString(outputFile, toMarkdown(report), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String toMarkdown(DiagnosisEvalDiffReport report) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("# Diagnosis Eval Baseline Diff\n\n");
|
||||
builder.append("- Baseline pass rate: ").append(formatPercent(report.getBaselinePassRate())).append("\n");
|
||||
builder.append("- Current pass rate: ").append(formatPercent(report.getCurrentPassRate())).append("\n");
|
||||
builder.append("- Baseline passed cases: ").append(report.getBaselinePassedCases()).append("/")
|
||||
.append(report.getBaselineTotalCases()).append("\n");
|
||||
builder.append("- Current passed cases: ").append(report.getCurrentPassedCases()).append("/")
|
||||
.append(report.getCurrentTotalCases()).append("\n");
|
||||
builder.append("- Regressions: ").append(report.getRegressionCount()).append("\n");
|
||||
builder.append("- Improvements: ").append(report.getImprovementCount()).append("\n");
|
||||
builder.append("- Other changes: ").append(report.getChangedCount()).append("\n\n");
|
||||
|
||||
builder.append("## Diff Items\n\n");
|
||||
if (report.getItems() == null || report.getItems().isEmpty()) {
|
||||
builder.append("- No differences\n");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
builder.append("| Type | Scope | Case | Metric | Baseline | Current | Delta | Message |\n");
|
||||
builder.append("| --- | --- | --- | --- | --- | --- | ---: | --- |\n");
|
||||
for (DiagnosisEvalDiffItem item : report.getItems()) {
|
||||
builder.append("| ")
|
||||
.append(valueOrDash(item.getType()))
|
||||
.append(" | ")
|
||||
.append(valueOrDash(item.getScope()))
|
||||
.append(" | ")
|
||||
.append(valueOrDash(item.getCaseId()))
|
||||
.append(" | ")
|
||||
.append(valueOrDash(item.getMetric()))
|
||||
.append(" | ")
|
||||
.append(valueOrDash(item.getBaselineValue()))
|
||||
.append(" | ")
|
||||
.append(valueOrDash(item.getCurrentValue()))
|
||||
.append(" | ")
|
||||
.append(item.getDelta() == null ? "-" : String.format("%.3f", item.getDelta()))
|
||||
.append(" | ")
|
||||
.append(valueOrDash(item.getMessage()))
|
||||
.append(" |\n");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String formatPercent(double value) {
|
||||
return String.format("%.2f%%", value * 100);
|
||||
}
|
||||
|
||||
private String valueOrDash(String value) {
|
||||
return value == null || value.isBlank() ? "-" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DiagnosisEvalReport {
|
||||
|
||||
private int totalCases;
|
||||
private int passedCases;
|
||||
private double passRate;
|
||||
private Map<String, Long> verdictDistribution;
|
||||
private double averageToolCallCount;
|
||||
private double averageDurationMs;
|
||||
private List<DiagnosisEvalResult> results;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
public class DiagnosisEvalReportWriter {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public DiagnosisEvalReportWriter(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public void writeJson(DiagnosisEvalReport report, Path outputFile) throws IOException {
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
objectMapper.writerWithDefaultPrettyPrinter().writeValue(outputFile.toFile(), report);
|
||||
}
|
||||
|
||||
public void writeMarkdown(DiagnosisEvalReport report, Path outputFile) throws IOException {
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
Files.writeString(outputFile, toMarkdown(report), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public String toMarkdown(DiagnosisEvalReport report) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("# Diagnosis Eval Report\n\n");
|
||||
builder.append("- Total cases: ").append(report.getTotalCases()).append("\n");
|
||||
builder.append("- Passed cases: ").append(report.getPassedCases()).append("\n");
|
||||
builder.append("- Pass rate: ").append(String.format("%.2f%%", report.getPassRate() * 100)).append("\n");
|
||||
builder.append("- Average tool calls: ").append(String.format("%.2f", report.getAverageToolCallCount())).append("\n");
|
||||
builder.append("- Average duration ms: ").append(String.format("%.2f", report.getAverageDurationMs())).append("\n\n");
|
||||
|
||||
builder.append("## Verdict Distribution\n\n");
|
||||
if (report.getVerdictDistribution() == null || report.getVerdictDistribution().isEmpty()) {
|
||||
builder.append("- None\n\n");
|
||||
} else {
|
||||
for (Map.Entry<String, Long> entry : report.getVerdictDistribution().entrySet()) {
|
||||
builder.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n");
|
||||
}
|
||||
builder.append("\n");
|
||||
}
|
||||
|
||||
builder.append("## Cases\n\n");
|
||||
builder.append("| Case | Result | Verdict | Keywords | Tool Calls | Duration ms | Failed Checks |\n");
|
||||
builder.append("| --- | --- | --- | --- | ---: | ---: | --- |\n");
|
||||
for (DiagnosisEvalResult result : report.getResults()) {
|
||||
builder.append("| ")
|
||||
.append(result.getCaseId())
|
||||
.append(" | ")
|
||||
.append(result.isPassed() ? "PASS" : "FAIL")
|
||||
.append(" | ")
|
||||
.append(valueOrDash(result.getVerdict()))
|
||||
.append(" | ")
|
||||
.append(result.getMatchedKeywordCount()).append("/").append(result.getRequiredKeywordCount())
|
||||
.append(" | ")
|
||||
.append(result.getToolCallCount() == null ? "-" : result.getToolCallCount())
|
||||
.append(" | ")
|
||||
.append(result.getDurationMs() == null ? "-" : result.getDurationMs())
|
||||
.append(" | ")
|
||||
.append(result.getFailedChecks() == null || result.getFailedChecks().isEmpty()
|
||||
? "-"
|
||||
: String.join("; ", result.getFailedChecks()))
|
||||
.append(" |\n");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String valueOrDash(String value) {
|
||||
return value == null || value.isBlank() ? "-" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DiagnosisEvalResult {
|
||||
|
||||
private String caseId;
|
||||
private String title;
|
||||
private boolean passed;
|
||||
private List<String> failedChecks;
|
||||
private String verdict;
|
||||
private int matchedKeywordCount;
|
||||
private int requiredKeywordCount;
|
||||
private Map<String, Boolean> evidenceCoverage;
|
||||
private Integer toolCallCount;
|
||||
private Integer durationMs;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.superbiz.agent.eval;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.dto.DiagnosisTraceResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class DiagnosisTraceEvaluator {
|
||||
|
||||
private static final TypeReference<List<DiagnosisEvalCase>> CASE_LIST_TYPE = new TypeReference<>() {};
|
||||
private static final String REJECT_DEGRADED_PREFIX = "当前无法基于已获取证据生成可靠结论";
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public DiagnosisTraceEvaluator(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public List<DiagnosisEvalCase> loadCases(Path casesFile) throws IOException {
|
||||
return objectMapper.readValue(casesFile.toFile(), CASE_LIST_TYPE);
|
||||
}
|
||||
|
||||
public DiagnosisTraceResponse loadTrace(Path traceFile) throws IOException {
|
||||
return objectMapper.readValue(traceFile.toFile(), DiagnosisTraceResponse.class);
|
||||
}
|
||||
|
||||
public DiagnosisEvalReport evaluate(List<DiagnosisEvalCase> cases, Path fixtureDir) {
|
||||
List<DiagnosisEvalResult> results = new ArrayList<>();
|
||||
for (DiagnosisEvalCase evalCase : cases) {
|
||||
try {
|
||||
DiagnosisTraceResponse trace = loadTrace(fixtureDir.resolve(evalCase.getTraceFixture()));
|
||||
results.add(evaluate(evalCase, trace));
|
||||
} catch (Exception e) {
|
||||
results.add(DiagnosisEvalResult.builder()
|
||||
.caseId(evalCase.getId())
|
||||
.title(evalCase.getTitle())
|
||||
.passed(false)
|
||||
.failedChecks(List.of("trace fixture unavailable: " + e.getMessage()))
|
||||
.verdict(null)
|
||||
.matchedKeywordCount(0)
|
||||
.requiredKeywordCount(size(evalCase.getExpectedRootCauseKeywords()))
|
||||
.evidenceCoverage(emptyCoverage(evalCase.getRequiredEvidenceTools()))
|
||||
.toolCallCount(null)
|
||||
.durationMs(null)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
return toReport(results);
|
||||
}
|
||||
|
||||
public DiagnosisEvalResult evaluate(DiagnosisEvalCase evalCase, DiagnosisTraceResponse trace) {
|
||||
List<String> failedChecks = new ArrayList<>();
|
||||
String answer = trace.getSession() == null ? "" : nullToEmpty(trace.getSession().getAnswer());
|
||||
String normalizedAnswer = answer.toLowerCase(Locale.ROOT);
|
||||
|
||||
int requiredKeywordCount = size(evalCase.getExpectedRootCauseKeywords());
|
||||
int matchedKeywordCount = countMatches(normalizedAnswer, evalCase.getExpectedRootCauseKeywords());
|
||||
int minKeywordMatches = evalCase.getMinKeywordMatches() == null
|
||||
? requiredKeywordCount
|
||||
: evalCase.getMinKeywordMatches();
|
||||
if (matchedKeywordCount < minKeywordMatches) {
|
||||
failedChecks.add("answer keyword coverage too low: " + matchedKeywordCount + "/" + minKeywordMatches);
|
||||
}
|
||||
|
||||
for (String forbidden : safeList(evalCase.getForbiddenAnswerKeywords())) {
|
||||
if (normalizedAnswer.contains(forbidden.toLowerCase(Locale.ROOT))) {
|
||||
failedChecks.add("answer contains forbidden keyword: " + forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
Set<String> evidenceTools = collectEvidenceTools(trace);
|
||||
Map<String, Boolean> evidenceCoverage = new LinkedHashMap<>();
|
||||
for (String requiredTool : safeList(evalCase.getRequiredEvidenceTools())) {
|
||||
boolean present = evidenceTools.contains(requiredTool);
|
||||
evidenceCoverage.put(requiredTool, present);
|
||||
if (!present) {
|
||||
failedChecks.add("missing required evidence tool: " + requiredTool);
|
||||
}
|
||||
}
|
||||
|
||||
String verdict = extractVerifierVerdict(trace);
|
||||
if (verdict == null || verdict.isBlank()) {
|
||||
failedChecks.add("missing verifier verdict");
|
||||
} else if (!safeList(evalCase.getAllowedVerdicts()).isEmpty()
|
||||
&& !safeList(evalCase.getAllowedVerdicts()).contains(verdict)) {
|
||||
failedChecks.add("verdict not allowed: " + verdict);
|
||||
}
|
||||
|
||||
if ("REJECT".equals(verdict) && !answer.startsWith(REJECT_DEGRADED_PREFIX)) {
|
||||
failedChecks.add("reject output does not use degraded template");
|
||||
}
|
||||
|
||||
Integer toolCallCount = trace.getToolInvocations() == null ? 0 : trace.getToolInvocations().size();
|
||||
Integer durationMs = trace.getSession() == null ? null : trace.getSession().getTotalDurationMs();
|
||||
|
||||
return DiagnosisEvalResult.builder()
|
||||
.caseId(evalCase.getId())
|
||||
.title(evalCase.getTitle())
|
||||
.passed(failedChecks.isEmpty())
|
||||
.failedChecks(failedChecks)
|
||||
.verdict(verdict)
|
||||
.matchedKeywordCount(matchedKeywordCount)
|
||||
.requiredKeywordCount(requiredKeywordCount)
|
||||
.evidenceCoverage(evidenceCoverage)
|
||||
.toolCallCount(toolCallCount)
|
||||
.durationMs(durationMs)
|
||||
.build();
|
||||
}
|
||||
|
||||
private DiagnosisEvalReport toReport(List<DiagnosisEvalResult> results) {
|
||||
int total = results.size();
|
||||
int passed = (int) results.stream().filter(DiagnosisEvalResult::isPassed).count();
|
||||
Map<String, Long> verdictDistribution = results.stream()
|
||||
.map(DiagnosisEvalResult::getVerdict)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.groupingBy(value -> value, LinkedHashMap::new, Collectors.counting()));
|
||||
double averageToolCallCount = results.stream()
|
||||
.map(DiagnosisEvalResult::getToolCallCount)
|
||||
.filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue)
|
||||
.average()
|
||||
.orElse(0.0);
|
||||
double averageDurationMs = results.stream()
|
||||
.map(DiagnosisEvalResult::getDurationMs)
|
||||
.filter(Objects::nonNull)
|
||||
.mapToInt(Integer::intValue)
|
||||
.average()
|
||||
.orElse(0.0);
|
||||
|
||||
return DiagnosisEvalReport.builder()
|
||||
.totalCases(total)
|
||||
.passedCases(passed)
|
||||
.passRate(total == 0 ? 0.0 : (double) passed / total)
|
||||
.verdictDistribution(verdictDistribution)
|
||||
.averageToolCallCount(averageToolCallCount)
|
||||
.averageDurationMs(averageDurationMs)
|
||||
.results(results)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Set<String> collectEvidenceTools(DiagnosisTraceResponse trace) {
|
||||
Set<String> tools = new LinkedHashSet<>();
|
||||
if (trace.getToolInvocations() != null) {
|
||||
for (DiagnosisTraceResponse.ToolInvocationTrace invocation : trace.getToolInvocations()) {
|
||||
if (invocation.getToolName() != null) {
|
||||
tools.add(invocation.getToolName());
|
||||
}
|
||||
}
|
||||
}
|
||||
Object summaries = nestedValue(trace, "verifier_evaluation", "tool_trace_summary");
|
||||
if (summaries instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> map && map.get("tool_name") != null) {
|
||||
tools.add(String.valueOf(map.get("tool_name")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
private String extractVerifierVerdict(DiagnosisTraceResponse trace) {
|
||||
Object value = nestedValue(trace, "verifier_evaluation", "verdict");
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private Object nestedValue(DiagnosisTraceResponse trace, String firstKey, String secondKey) {
|
||||
if (trace.getSession() == null || trace.getSession().getSelfEvaluation() == null) {
|
||||
return null;
|
||||
}
|
||||
Object first = trace.getSession().getSelfEvaluation().get(firstKey);
|
||||
if (!(first instanceof Map<?, ?> map)) {
|
||||
return null;
|
||||
}
|
||||
return map.get(secondKey);
|
||||
}
|
||||
|
||||
private int countMatches(String normalizedAnswer, List<String> keywords) {
|
||||
int count = 0;
|
||||
for (String keyword : safeList(keywords)) {
|
||||
if (normalizedAnswer.contains(keyword.toLowerCase(Locale.ROOT))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private Map<String, Boolean> emptyCoverage(List<String> tools) {
|
||||
Map<String, Boolean> coverage = new LinkedHashMap<>();
|
||||
for (String tool : safeList(tools)) {
|
||||
coverage.put(tool, false);
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
|
||||
private List<String> safeList(List<String> values) {
|
||||
return values == null ? List.of() : values;
|
||||
}
|
||||
|
||||
private int size(List<?> values) {
|
||||
return values == null ? 0 : values.size();
|
||||
}
|
||||
|
||||
private String nullToEmpty(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,16 @@ package com.superbiz.agent.service;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.repository.ToolInvocationRepository;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import com.superbiz.agent.util.SessionContextHolder;
|
||||
import lombok.Builder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -21,6 +26,10 @@ import java.util.UUID;
|
||||
public class ToolInvocationRecorder {
|
||||
|
||||
private static final int OUTPUT_PREVIEW_LIMIT = 500;
|
||||
public static final String EVIDENCE_STATUS_SUPPORTED = "supported";
|
||||
public static final String EVIDENCE_STATUS_NO_EVIDENCE = "no_evidence";
|
||||
public static final String EVIDENCE_STATUS_DEDUPED = "deduped";
|
||||
public static final String EVIDENCE_STATUS_FAILED = "failed";
|
||||
|
||||
private final ToolInvocationRepository toolInvocationRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -52,12 +61,29 @@ public class ToolInvocationRecorder {
|
||||
long startTimeMillis,
|
||||
String errorMessage,
|
||||
String topicDomain) {
|
||||
recordEvidenceTool(toolName, inputParams, output, success, startTimeMillis, errorMessage, topicDomain,
|
||||
success ? EVIDENCE_STATUS_SUPPORTED : EVIDENCE_STATUS_FAILED, Map.of());
|
||||
}
|
||||
|
||||
public void recordEvidenceTool(String toolName,
|
||||
Map<String, Object> inputParams,
|
||||
String output,
|
||||
boolean success,
|
||||
long startTimeMillis,
|
||||
String errorMessage,
|
||||
String topicDomain,
|
||||
String evidenceStatus,
|
||||
Map<String, Object> extraDetails) {
|
||||
String outputPreview = preview(output);
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
details.put("trace_id", UUID.randomUUID().toString());
|
||||
if (topicDomain != null && !topicDomain.isBlank()) {
|
||||
details.put("retrieved_domains", List.of(topicDomain));
|
||||
}
|
||||
details.put("evidence_status", normalizeEvidenceStatus(success, evidenceStatus));
|
||||
if (extraDetails != null && !extraDetails.isEmpty()) {
|
||||
details.putAll(extraDetails);
|
||||
}
|
||||
|
||||
ToolInvocation invocation = ToolInvocation.builder()
|
||||
.toolName(toolName)
|
||||
@@ -73,6 +99,67 @@ public class ToolInvocationRecorder {
|
||||
save(invocation);
|
||||
}
|
||||
|
||||
public void recordLookupKnowledge(LookupKnowledgeRecord record) {
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
details.put("trace_id", UUID.randomUUID().toString());
|
||||
if (record.l0MatchCount() != null) {
|
||||
details.put("l0_match_count", record.l0MatchCount());
|
||||
}
|
||||
if (record.l0Titles() != null && !record.l0Titles().isEmpty()) {
|
||||
details.put("l0_titles", record.l0Titles());
|
||||
}
|
||||
if (record.l1TopScore() != null) {
|
||||
details.put("l1_top_score", record.l1TopScore());
|
||||
}
|
||||
if (record.l1TopSimilarity() != null) {
|
||||
details.put("l1_top_similarity", record.l1TopSimilarity());
|
||||
}
|
||||
if (record.l1MatchCount() != null) {
|
||||
details.put("l1_match_count", record.l1MatchCount());
|
||||
}
|
||||
if (record.l1Scores() != null && !record.l1Scores().isEmpty()) {
|
||||
details.put("l1_scores", record.l1Scores());
|
||||
}
|
||||
if (record.relevanceLevel() != null) {
|
||||
details.put("relevance_level", record.relevanceLevel());
|
||||
}
|
||||
if (record.completenessHint() != null) {
|
||||
details.put("completeness_hint", record.completenessHint());
|
||||
}
|
||||
if (record.domain() != null && !record.domain().isBlank()) {
|
||||
details.put("retrieved_domains", List.of(record.domain()));
|
||||
}
|
||||
if (record.dedupReason() != null) {
|
||||
details.put("dedup_reason", record.dedupReason());
|
||||
}
|
||||
details.put("evidence_status", normalizeEvidenceStatus(record.success(), record.evidenceStatus()));
|
||||
|
||||
ToolInvocation invocation = ToolInvocation.builder()
|
||||
.toolName("lookup_knowledge")
|
||||
.inputParams(toJson(Map.of("query", record.query())))
|
||||
.outputPreview(preview(record.outputPreview()))
|
||||
.outputLength(record.outputLength())
|
||||
.retrievalLayer(record.retrievalLayer())
|
||||
.l0MatchCount(record.l0MatchCount())
|
||||
.l1MatchCount(record.l1MatchCount())
|
||||
.isTruncated(Boolean.TRUE.equals(record.truncated()))
|
||||
.retrievalDetails(toJson(details))
|
||||
.relevanceLevel(record.relevanceLevel())
|
||||
.dedupReason(record.dedupReason())
|
||||
.durationMs(record.durationMs())
|
||||
.success(record.success())
|
||||
.errorMessage(record.errorMessage())
|
||||
.build();
|
||||
save(invocation);
|
||||
}
|
||||
|
||||
private String normalizeEvidenceStatus(boolean success, String evidenceStatus) {
|
||||
if (evidenceStatus != null && !evidenceStatus.isBlank()) {
|
||||
return evidenceStatus;
|
||||
}
|
||||
return success ? EVIDENCE_STATUS_SUPPORTED : EVIDENCE_STATUS_FAILED;
|
||||
}
|
||||
|
||||
private String preview(String output) {
|
||||
if (output == null) {
|
||||
return null;
|
||||
@@ -90,4 +177,105 @@ public class ToolInvocationRecorder {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
@Builder
|
||||
public record LookupKnowledgeRecord(
|
||||
String query,
|
||||
String outputPreview,
|
||||
Integer outputLength,
|
||||
String retrievalLayer,
|
||||
Integer l0MatchCount,
|
||||
Integer l1MatchCount,
|
||||
Boolean truncated,
|
||||
String relevanceLevel,
|
||||
String completenessHint,
|
||||
String domain,
|
||||
String dedupReason,
|
||||
Integer durationMs,
|
||||
boolean success,
|
||||
String evidenceStatus,
|
||||
String errorMessage,
|
||||
List<String> l0Titles,
|
||||
Double l1TopScore,
|
||||
Double l1TopSimilarity,
|
||||
List<Double> l1Scores
|
||||
) {
|
||||
public static LookupKnowledgeRecord from(String query,
|
||||
List<KnowledgeEntry> l0Matches,
|
||||
List<VectorSearchService.SearchResult> l1Results,
|
||||
boolean highConfidence,
|
||||
LookupResult result,
|
||||
String domain,
|
||||
String dedupReason,
|
||||
int durationMs,
|
||||
double l1TopSimilarity) {
|
||||
boolean hasL0 = l0Matches != null && !l0Matches.isEmpty();
|
||||
boolean hasL1 = l1Results != null && !l1Results.isEmpty();
|
||||
String layer;
|
||||
if (hasL0 && !highConfidence) {
|
||||
layer = "L0+L1";
|
||||
} else if (hasL0) {
|
||||
layer = "L0";
|
||||
} else if (hasL1) {
|
||||
layer = "L1";
|
||||
} else {
|
||||
layer = null;
|
||||
}
|
||||
|
||||
String outputPreview = null;
|
||||
int outputLength = 0;
|
||||
boolean truncated = false;
|
||||
if (result != null && result.getPrimary() != null && result.getPrimary().getContent() != null) {
|
||||
outputPreview = result.getPrimary().getContent();
|
||||
outputLength = outputPreview.length();
|
||||
truncated = outputLength > OUTPUT_PREVIEW_LIMIT;
|
||||
} else if (hasL1 && l1Results.get(0).getContent() != null) {
|
||||
outputPreview = l1Results.get(0).getContent();
|
||||
outputLength = outputPreview.length();
|
||||
truncated = outputLength > OUTPUT_PREVIEW_LIMIT;
|
||||
}
|
||||
|
||||
String evidenceStatus = EVIDENCE_STATUS_SUPPORTED;
|
||||
if (dedupReason != null) {
|
||||
evidenceStatus = EVIDENCE_STATUS_DEDUPED;
|
||||
} else if (result == null || !result.isFound()) {
|
||||
evidenceStatus = EVIDENCE_STATUS_NO_EVIDENCE;
|
||||
}
|
||||
|
||||
List<String> l0Titles = new ArrayList<>();
|
||||
if (hasL0) {
|
||||
for (int i = 0; i < Math.min(3, l0Matches.size()); i++) {
|
||||
l0Titles.add(l0Matches.get(i).getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
List<Double> l1Scores = new ArrayList<>();
|
||||
if (hasL1) {
|
||||
for (int i = 0; i < Math.min(3, l1Results.size()); i++) {
|
||||
l1Scores.add((double) l1Results.get(i).getScore());
|
||||
}
|
||||
}
|
||||
|
||||
return LookupKnowledgeRecord.builder()
|
||||
.query(query)
|
||||
.outputPreview(outputPreview)
|
||||
.outputLength(outputLength)
|
||||
.retrievalLayer(layer)
|
||||
.l0MatchCount(hasL0 ? l0Matches.size() : null)
|
||||
.l1MatchCount(hasL1 ? l1Results.size() : null)
|
||||
.truncated(truncated)
|
||||
.relevanceLevel(result != null ? result.getRelevanceLevel() : null)
|
||||
.completenessHint(result != null ? result.getCompletenessHint() : null)
|
||||
.domain(domain)
|
||||
.dedupReason(dedupReason)
|
||||
.durationMs(durationMs)
|
||||
.success(true)
|
||||
.evidenceStatus(evidenceStatus)
|
||||
.l0Titles(l0Titles)
|
||||
.l1TopScore(hasL1 ? (double) l1Results.get(0).getScore() : null)
|
||||
.l1TopSimilarity(hasL1 ? l1TopSimilarity : null)
|
||||
.l1Scores(l1Scores)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ public class ToolTraceSummaryService {
|
||||
}
|
||||
|
||||
private String extractOutputSummary(ToolInvocation invocation, String topicDomain) {
|
||||
String evidenceStatus = extractEvidenceStatus(invocation);
|
||||
if (!Boolean.TRUE.equals(invocation.getSuccess())) {
|
||||
if (invocation.getErrorMessage() != null && !invocation.getErrorMessage().isBlank()) {
|
||||
return "call failed: " + truncate(invocation.getErrorMessage(), 120);
|
||||
@@ -114,6 +115,17 @@ public class ToolTraceSummaryService {
|
||||
return "no usable evidence returned";
|
||||
}
|
||||
|
||||
if (ToolInvocationRecorder.EVIDENCE_STATUS_DEDUPED.equals(evidenceStatus)) {
|
||||
return "retrieval skipped because the same document was already used in this session";
|
||||
}
|
||||
|
||||
if (ToolInvocationRecorder.EVIDENCE_STATUS_NO_EVIDENCE.equals(evidenceStatus)) {
|
||||
if (invocation.getOutputPreview() != null && !invocation.getOutputPreview().isBlank()) {
|
||||
return "completed without usable evidence: " + truncate(invocation.getOutputPreview(), 120);
|
||||
}
|
||||
return "completed without usable evidence";
|
||||
}
|
||||
|
||||
if ("lookup_knowledge".equals(invocation.getToolName())) {
|
||||
String relevance = invocation.getRelevanceLevel() != null ? invocation.getRelevanceLevel() : "UNKNOWN";
|
||||
String preview = invocation.getOutputPreview() != null && !invocation.getOutputPreview().isBlank()
|
||||
@@ -129,18 +141,46 @@ public class ToolTraceSummaryService {
|
||||
}
|
||||
|
||||
private String determineEvidenceLevel(ToolInvocation invocation) {
|
||||
String evidenceStatus = extractEvidenceStatus(invocation);
|
||||
if (!Boolean.TRUE.equals(invocation.getSuccess())) {
|
||||
return "none";
|
||||
}
|
||||
if (ToolInvocationRecorder.EVIDENCE_STATUS_NO_EVIDENCE.equals(evidenceStatus)
|
||||
|| ToolInvocationRecorder.EVIDENCE_STATUS_DEDUPED.equals(evidenceStatus)) {
|
||||
return "none";
|
||||
}
|
||||
if ("PRECISE".equals(invocation.getRelevanceLevel()) || "HIGHLY_RELEVANT".equals(invocation.getRelevanceLevel())) {
|
||||
return "direct";
|
||||
}
|
||||
if ("REFERENCE".equals(invocation.getRelevanceLevel())) {
|
||||
return "indirect";
|
||||
}
|
||||
if (EVIDENCE_TOOLS.contains(invocation.getToolName())) {
|
||||
return "direct";
|
||||
}
|
||||
return "none";
|
||||
}
|
||||
|
||||
private String extractEvidenceStatus(ToolInvocation invocation) {
|
||||
if (invocation.getRetrievalDetails() == null || invocation.getRetrievalDetails().isBlank()) {
|
||||
return Boolean.TRUE.equals(invocation.getSuccess())
|
||||
? ToolInvocationRecorder.EVIDENCE_STATUS_SUPPORTED
|
||||
: ToolInvocationRecorder.EVIDENCE_STATUS_FAILED;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> details = objectMapper.readValue(invocation.getRetrievalDetails(), MAP_TYPE);
|
||||
Object evidenceStatus = details.get("evidence_status");
|
||||
if (evidenceStatus != null) {
|
||||
return String.valueOf(evidenceStatus);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse evidence_status", e);
|
||||
}
|
||||
return Boolean.TRUE.equals(invocation.getSuccess())
|
||||
? ToolInvocationRecorder.EVIDENCE_STATUS_SUPPORTED
|
||||
: ToolInvocationRecorder.EVIDENCE_STATUS_FAILED;
|
||||
}
|
||||
|
||||
private List<String> extractStringList(Object value) {
|
||||
if (!(value instanceof List<?> list) || list.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -224,13 +264,22 @@ public class ToolTraceSummaryService {
|
||||
inputSummary = extractInputSummary(invocation);
|
||||
}
|
||||
|
||||
boolean invocationSuccess = Boolean.TRUE.equals(invocation.getSuccess());
|
||||
if (!invocationSuccess) {
|
||||
String evidenceStatus = extractEvidenceStatus(invocation);
|
||||
if (!Boolean.TRUE.equals(invocation.getSuccess())) {
|
||||
failedCount++;
|
||||
if (outputSummary == null || outputSummary.isBlank()) {
|
||||
outputSummary = extractOutputSummary(invocation, topicDomain);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (invocation.getDedupReason() != null) {
|
||||
|
||||
if (ToolInvocationRecorder.EVIDENCE_STATUS_NO_EVIDENCE.equals(evidenceStatus)
|
||||
|| ToolInvocationRecorder.EVIDENCE_STATUS_DEDUPED.equals(evidenceStatus)) {
|
||||
noHitCount++;
|
||||
if (outputSummary == null || outputSummary.isBlank()) {
|
||||
outputSummary = extractOutputSummary(invocation, topicDomain);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String invocationEvidenceLevel = determineEvidenceLevel(invocation);
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -309,131 +310,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,
|
||||
l0Matches,
|
||||
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(
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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<DiagnosisEvalCase> 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<DiagnosisEvalCase> 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();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,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;
|
||||
@@ -86,6 +87,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();
|
||||
@@ -178,7 +246,8 @@ class ChatServiceSequentialAgentTest {
|
||||
private final java.util.ArrayList<String> agentCalls = new java.util.ArrayList<>();
|
||||
private String promptText = "";
|
||||
private boolean sawVerifierPrompt;
|
||||
private final String verifierOutput;
|
||||
private final java.util.List<String> verifierOutputs;
|
||||
private int verifierOutputIndex;
|
||||
|
||||
private ScriptedChatModel() {
|
||||
this("""
|
||||
@@ -201,7 +270,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
|
||||
@@ -217,7 +290,9 @@ class ChatServiceSequentialAgentTest {
|
||||
} else if (promptText.contains("VERIFIER_TEST_PROMPT")) {
|
||||
agentCalls.add("chat_verifier");
|
||||
sawVerifierPrompt = true;
|
||||
text = verifierOutput;
|
||||
int index = Math.min(verifierOutputIndex, verifierOutputs.size() - 1);
|
||||
text = verifierOutputs.get(index);
|
||||
verifierOutputIndex++;
|
||||
} else {
|
||||
text = "UNEXPECTED_PROMPT";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.superbiz.agent.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||
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<ToolInvocation> 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"))
|
||||
.build();
|
||||
|
||||
try {
|
||||
recorder.recordLookupKnowledge(record);
|
||||
} finally {
|
||||
SessionContextHolder.clear();
|
||||
}
|
||||
|
||||
ArgumentCaptor<ToolInvocation> 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\"]"));
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, Object>> summaries = service.buildVerifierTraceSummary("session-1", "application-logs point to timeout");
|
||||
|
||||
assertEquals(2, summaries.size());
|
||||
|
||||
Map<String, Object> logsSummary = summaries.stream()
|
||||
.filter(item -> "query_logs".equals(item.get("tool_name")))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals(Boolean.TRUE, logsSummary.get("success"));
|
||||
assertEquals("direct", logsSummary.get("evidence_level"));
|
||||
assertEquals(2, logsSummary.get("invocation_count"));
|
||||
assertEquals(1, logsSummary.get("no_hit_invocation_count"));
|
||||
|
||||
Map<String, Object> metricsSummary = summaries.stream()
|
||||
.filter(item -> "query_metrics".equals(item.get("tool_name")))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals(Boolean.FALSE, metricsSummary.get("success"));
|
||||
assertEquals("none", metricsSummary.get("evidence_level"));
|
||||
assertEquals(1, metricsSummary.get("failed_invocation_count"));
|
||||
assertTrue(String.valueOf(metricsSummary.get("output_summary")).contains("call failed"));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package com.superbiz.agent.tool;
|
||||
import com.superbiz.agent.dto.KnowledgeEntry;
|
||||
import com.superbiz.agent.dto.LookupResult;
|
||||
import com.superbiz.agent.service.KnowledgeIndexService;
|
||||
import com.superbiz.agent.service.ToolInvocationRecorder;
|
||||
import com.superbiz.agent.service.VectorSearchService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
@@ -28,6 +30,15 @@ class LookupKnowledgeToolTest {
|
||||
@Mock
|
||||
private VectorSearchService vectorSearchService;
|
||||
|
||||
@Mock
|
||||
private ToolInvocationRecorder toolInvocationRecorder;
|
||||
|
||||
@Mock
|
||||
private RetrievedDocTracker retrievedDocTracker;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private LookupKnowledgeTool tool;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user