feat(eval): add executor audit closure checks
This commit is contained in:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user