feat(eval): add executor audit closure checks

This commit is contained in:
aruo
2026-07-08 10:21:39 +08:00
parent 6015bcbf6f
commit a08672b31e
20 changed files with 1142 additions and 188 deletions
@@ -22,4 +22,10 @@ public class DiagnosisEvalCase {
private List<String> requiredEvidenceTools;
private List<String> allowedVerdicts;
private List<String> forbiddenAnswerKeywords;
private Boolean requireV2AuditClosure;
private Boolean requireClaimChecks;
private Boolean requireComposerOutput;
private List<String> expectedGatekeeperStatuses;
private List<String> expectedComposerStatuses;
private List<String> forbiddenConfirmedClaimKeywords;
}
@@ -46,8 +46,8 @@ public class DiagnosisEvalReportWriter {
}
builder.append("## Cases\n\n");
builder.append("| Case | Result | Verdict | Keywords | Tool Calls | Duration ms | Failed Checks |\n");
builder.append("| --- | --- | --- | --- | ---: | ---: | --- |\n");
builder.append("| Case | Result | Verdict | Gatekeeper | Composer | Claim Checks | Keywords | Tool Calls | Duration ms | Failed Checks |\n");
builder.append("| --- | --- | --- | --- | --- | ---: | --- | ---: | ---: | --- |\n");
for (DiagnosisEvalResult result : report.getResults()) {
builder.append("| ")
.append(result.getCaseId())
@@ -56,6 +56,12 @@ public class DiagnosisEvalReportWriter {
.append(" | ")
.append(valueOrDash(result.getVerdict()))
.append(" | ")
.append(valueOrDash(result.getGatekeeperStatus()))
.append(" | ")
.append(valueOrDash(result.getComposerStatus()))
.append(" | ")
.append(result.getClaimCheckCount() == null ? "-" : result.getClaimCheckCount())
.append(" | ")
.append(result.getMatchedKeywordCount()).append("/").append(result.getRequiredKeywordCount())
.append(" | ")
.append(result.getToolCallCount() == null ? "-" : result.getToolCallCount())
@@ -22,6 +22,9 @@ public class DiagnosisEvalResult {
private int matchedKeywordCount;
private int requiredKeywordCount;
private Map<String, Boolean> evidenceCoverage;
private String gatekeeperStatus;
private String composerStatus;
private Integer claimCheckCount;
private Integer toolCallCount;
private Integer durationMs;
}
@@ -20,6 +20,20 @@ public class DiagnosisTraceEvaluator {
private static final TypeReference<List<DiagnosisEvalCase>> CASE_LIST_TYPE = new TypeReference<>() {};
private static final String REJECT_DEGRADED_PREFIX = "当前无法基于已获取证据生成可靠结论";
private static final Set<String> RAW_EXECUTOR_MARKERS = Set.of(
"executor_evidence_v2",
"answer_version",
"evidence_bindings",
"claim_id"
);
private static final Set<String> VALID_CLAIM_VERIFICATIONS = Set.of(
"direct_observation",
"reasonable_inference",
"overstated",
"unsupported",
"external_unknown",
"contradicted"
);
private final ObjectMapper objectMapper;
@@ -51,6 +65,9 @@ public class DiagnosisTraceEvaluator {
.matchedKeywordCount(0)
.requiredKeywordCount(size(evalCase.getExpectedRootCauseKeywords()))
.evidenceCoverage(emptyCoverage(evalCase.getRequiredEvidenceTools()))
.gatekeeperStatus(null)
.composerStatus(null)
.claimCheckCount(null)
.toolCallCount(null)
.durationMs(null)
.build());
@@ -102,6 +119,11 @@ public class DiagnosisTraceEvaluator {
}
failedChecks.addAll(validateExecutorStructuredOutput(trace));
String gatekeeperStatus = extractNestedString(trace, "verifier_evaluation", "gatekeeper_result", "status");
String composerStatus = extractNestedString(trace, "verifier_evaluation", "composer_output", "status");
Integer claimCheckCount = countList(trace, "verifier_evaluation", "claim_checks");
failedChecks.addAll(validateV2AuditClosure(evalCase, trace, normalizedAnswer, verdict,
gatekeeperStatus, composerStatus));
Integer toolCallCount = trace.getToolInvocations() == null ? 0 : trace.getToolInvocations().size();
Integer durationMs = trace.getSession() == null ? null : trace.getSession().getTotalDurationMs();
@@ -115,6 +137,9 @@ public class DiagnosisTraceEvaluator {
.matchedKeywordCount(matchedKeywordCount)
.requiredKeywordCount(requiredKeywordCount)
.evidenceCoverage(evidenceCoverage)
.gatekeeperStatus(gatekeeperStatus)
.composerStatus(composerStatus)
.claimCheckCount(claimCheckCount)
.toolCallCount(toolCallCount)
.durationMs(durationMs)
.build();
@@ -202,6 +227,91 @@ public class DiagnosisTraceEvaluator {
return failedChecks;
}
private List<String> validateV2AuditClosure(DiagnosisEvalCase evalCase,
DiagnosisTraceResponse trace,
String normalizedAnswer,
String verdict,
String gatekeeperStatus,
String composerStatus) {
List<String> failedChecks = new ArrayList<>();
boolean requireV2AuditClosure = Boolean.TRUE.equals(evalCase.getRequireV2AuditClosure());
boolean requireClaimChecks = requireV2AuditClosure || Boolean.TRUE.equals(evalCase.getRequireClaimChecks());
boolean requireComposerOutput = requireV2AuditClosure || Boolean.TRUE.equals(evalCase.getRequireComposerOutput());
Object gatekeeperResult = nestedValue(trace, "verifier_evaluation", "gatekeeper_result");
if (requireV2AuditClosure && !(gatekeeperResult instanceof Map<?, ?>)) {
failedChecks.add("missing gatekeeper_result");
}
if ("fail".equals(gatekeeperStatus) && "PASS".equals(verdict)) {
failedChecks.add("gatekeeper fail cannot have PASS verdict");
}
if (!safeList(evalCase.getExpectedGatekeeperStatuses()).isEmpty()
&& !safeList(evalCase.getExpectedGatekeeperStatuses()).contains(gatekeeperStatus)) {
failedChecks.add("gatekeeper status not expected: " + valueOrMissing(gatekeeperStatus));
}
failedChecks.addAll(validateClaimChecks(trace, requireClaimChecks));
Object composerOutput = nestedValue(trace, "verifier_evaluation", "composer_output");
if (requireComposerOutput && !(composerOutput instanceof Map<?, ?>)) {
failedChecks.add("missing composer_output");
}
if (requireComposerOutput && isBlank(composerStatus)) {
failedChecks.add("composer_output missing status");
}
if (!safeList(evalCase.getExpectedComposerStatuses()).isEmpty()
&& !safeList(evalCase.getExpectedComposerStatuses()).contains(composerStatus)) {
failedChecks.add("composer status not expected: " + valueOrMissing(composerStatus));
}
for (String forbidden : safeList(evalCase.getForbiddenConfirmedClaimKeywords())) {
if (normalizedAnswer.contains(forbidden.toLowerCase(Locale.ROOT))) {
failedChecks.add("answer contains forbidden confirmed claim keyword: " + forbidden);
}
}
if (requireV2AuditClosure) {
for (String marker : RAW_EXECUTOR_MARKERS) {
if (normalizedAnswer.contains(marker.toLowerCase(Locale.ROOT))) {
failedChecks.add("answer leaks raw executor marker: " + marker);
}
}
}
return failedChecks;
}
private List<String> validateClaimChecks(DiagnosisTraceResponse trace, boolean required) {
Object claimChecks = nestedValue(trace, "verifier_evaluation", "claim_checks");
if (!(claimChecks instanceof List<?> claimCheckList)) {
return required ? List.of("missing claim_checks") : List.of();
}
if (required && claimCheckList.isEmpty()) {
return List.of("claim_checks is empty");
}
List<String> failedChecks = new ArrayList<>();
for (Object item : claimCheckList) {
if (!(item instanceof Map<?, ?> claimCheck)) {
failedChecks.add("claim_check is not an object");
continue;
}
String claimId = stringValue(claimCheck.get("claim_id"));
String verification = stringValue(claimCheck.get("verification"));
if (isBlank(claimId)) {
failedChecks.add("claim_check missing claim_id");
}
if (isBlank(verification)) {
failedChecks.add("claim_check missing verification: " + valueOrMissing(claimId));
} else if (!VALID_CLAIM_VERIFICATIONS.contains(verification)) {
failedChecks.add("claim_check verification invalid: " + verification);
}
if (isBlank(stringValue(claimCheck.get("detail")))) {
failedChecks.add("claim_check missing detail: " + valueOrMissing(claimId));
}
}
return failedChecks;
}
private Object nestedValue(DiagnosisTraceResponse trace, String firstKey, String secondKey) {
if (trace.getSession() == null || trace.getSession().getSelfEvaluation() == null) {
return null;
@@ -213,6 +323,20 @@ public class DiagnosisTraceEvaluator {
return map.get(secondKey);
}
private String extractNestedString(DiagnosisTraceResponse trace, String firstKey, String secondKey, String thirdKey) {
Object value = nestedValue(trace, firstKey, secondKey);
if (!(value instanceof Map<?, ?> map)) {
return null;
}
Object nested = map.get(thirdKey);
return nested == null ? null : String.valueOf(nested);
}
private Integer countList(DiagnosisTraceResponse trace, String firstKey, String secondKey) {
Object value = nestedValue(trace, firstKey, secondKey);
return value instanceof List<?> list ? list.size() : null;
}
private int countMatches(String normalizedAnswer, List<String> keywords) {
int count = 0;
for (String keyword : safeList(keywords)) {
@@ -242,4 +366,16 @@ public class DiagnosisTraceEvaluator {
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private String valueOrMissing(String value) {
return isBlank(value) ? "missing" : value;
}
}
@@ -100,14 +100,14 @@ class DiagnosisEvalBaselineDiffTest {
}
private void degradeRedisCase(DiagnosisEvalReport report) {
report.setPassedCases(4);
report.setPassRate(0.8);
report.setPassedCases(7);
report.setPassRate(0.875);
report.setAverageToolCallCount(3.0);
report.setAverageDurationMs(45800.0);
report.setAverageDurationMs(44875.0);
report.setVerdictDistribution(new LinkedHashMap<>());
report.getVerdictDistribution().put("PASS", 2L);
report.getVerdictDistribution().put("LOW_CONFID", 2L);
report.getVerdictDistribution().put("REJECT", 1L);
report.getVerdictDistribution().put("LOW_CONFID", 4L);
report.getVerdictDistribution().put("REJECT", 2L);
DiagnosisEvalResult redis = result(report, "redis-timeout");
redis.setPassed(false);
@@ -24,11 +24,12 @@ class DiagnosisTraceEvaluatorTest {
DiagnosisEvalReport report = evaluator.evaluate(cases, Path.of("mvp/eval/fixtures"));
assertEquals(5, report.getTotalCases());
assertEquals(5, report.getPassedCases());
assertEquals(8, report.getTotalCases());
assertEquals(8, report.getPassedCases());
assertEquals(1.0, report.getPassRate(), 0.001);
assertEquals(2L, report.getVerdictDistribution().get("PASS"));
assertEquals(3L, report.getVerdictDistribution().get("LOW_CONFID"));
assertEquals(5L, report.getVerdictDistribution().get("LOW_CONFID"));
assertEquals(1L, report.getVerdictDistribution().get("REJECT"));
DiagnosisEvalResult payment = result(report, "payment-timeout");
assertTrue(payment.isPassed());
@@ -39,6 +40,16 @@ class DiagnosisTraceEvaluatorTest {
DiagnosisEvalResult redis = result(report, "redis-timeout");
assertTrue(redis.isPassed());
assertTrue(redis.getEvidenceCoverage().get("query_logs"));
DiagnosisEvalResult fabricatedInvocation = result(report, "gatekeeper-fabricated-invocation");
assertTrue(fabricatedInvocation.isPassed());
assertEquals("fail", fabricatedInvocation.getGatekeeperStatus());
assertEquals("valid", fabricatedInvocation.getComposerStatus());
assertEquals(1, fabricatedInvocation.getClaimCheckCount());
DiagnosisEvalResult composerFallback = result(report, "composer-fallback-no-raw-json");
assertTrue(composerFallback.isPassed());
assertEquals("composer_malformed", composerFallback.getComposerStatus());
}
@Test
@@ -108,6 +119,111 @@ class DiagnosisTraceEvaluatorTest {
"executor confirmed claim missing evidence bindings: claim-unsupported"));
}
@Test
void evaluateFailsWhenGatekeeperFailStillPassesVerifier() {
DiagnosisEvalCase evalCase = DiagnosisEvalCase.builder()
.id("gatekeeper-pass-leak")
.title("Gatekeeper pass leak")
.expectedRootCauseKeywords(List.of())
.requiredEvidenceTools(List.of())
.allowedVerdicts(List.of("PASS", "LOW_CONFID", "REJECT"))
.requireV2AuditClosure(true)
.build();
DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder()
.session(DiagnosisTraceResponse.SessionTrace.builder()
.answer("安全回答")
.selfEvaluation(java.util.Map.of(
"verifier_evaluation", java.util.Map.of(
"verdict", "PASS",
"gatekeeper_result", java.util.Map.of("status", "fail"),
"claim_checks", java.util.List.of(java.util.Map.of(
"claim_id", "claim-1",
"verification", "unsupported",
"detail", "evidence ref invalid"
)),
"composer_output", java.util.Map.of("status", "valid")
)))
.build())
.toolInvocations(List.of())
.build();
DiagnosisEvalResult result = evaluator.evaluate(evalCase, trace);
assertFalse(result.isPassed());
assertTrue(result.getFailedChecks().contains("gatekeeper fail cannot have PASS verdict"));
}
@Test
void evaluateFailsWhenUnsupportedClaimLeaksIntoFinalAnswer() {
DiagnosisEvalCase evalCase = DiagnosisEvalCase.builder()
.id("unsupported-leak")
.title("Unsupported leak")
.expectedRootCauseKeywords(List.of())
.requiredEvidenceTools(List.of())
.allowedVerdicts(List.of("LOW_CONFID"))
.requireV2AuditClosure(true)
.forbiddenConfirmedClaimKeywords(List.of("主库故障"))
.build();
DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder()
.session(DiagnosisTraceResponse.SessionTrace.builder()
.answer("已经确认主库故障。")
.selfEvaluation(java.util.Map.of(
"verifier_evaluation", java.util.Map.of(
"verdict", "LOW_CONFID",
"gatekeeper_result", java.util.Map.of("status", "pass"),
"claim_checks", java.util.List.of(java.util.Map.of(
"claim_id", "claim-1",
"verification", "unsupported",
"detail", "missing database evidence"
)),
"composer_output", java.util.Map.of("status", "valid")
)))
.build())
.toolInvocations(List.of())
.build();
DiagnosisEvalResult result = evaluator.evaluate(evalCase, trace);
assertFalse(result.isPassed());
assertTrue(result.getFailedChecks().contains(
"answer contains forbidden confirmed claim keyword: 主库故障"));
}
@Test
void evaluateFailsWhenFinalAnswerLeaksRawExecutorMarker() {
DiagnosisEvalCase evalCase = DiagnosisEvalCase.builder()
.id("raw-json-leak")
.title("Raw json leak")
.expectedRootCauseKeywords(List.of())
.requiredEvidenceTools(List.of())
.allowedVerdicts(List.of("LOW_CONFID"))
.requireV2AuditClosure(true)
.build();
DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder()
.session(DiagnosisTraceResponse.SessionTrace.builder()
.answer("answer_version=executor_evidence_v2")
.selfEvaluation(java.util.Map.of(
"verifier_evaluation", java.util.Map.of(
"verdict", "LOW_CONFID",
"gatekeeper_result", java.util.Map.of("status", "pass"),
"claim_checks", java.util.List.of(java.util.Map.of(
"claim_id", "claim-1",
"verification", "direct_observation",
"detail", "log evidence"
)),
"composer_output", java.util.Map.of("status", "composer_malformed")
)))
.build())
.toolInvocations(List.of())
.build();
DiagnosisEvalResult result = evaluator.evaluate(evalCase, trace);
assertFalse(result.isPassed());
assertTrue(result.getFailedChecks().contains("answer leaks raw executor marker: executor_evidence_v2"));
assertTrue(result.getFailedChecks().contains("answer leaks raw executor marker: answer_version"));
}
@Test
void reportWriterOutputsJsonAndMarkdown(@TempDir Path tempDir) throws Exception {
DiagnosisEvalReport report = evaluator.evaluate(readCases(), Path.of("mvp/eval/fixtures"));